diff --git a/src/main/db.ts b/src/main/db.ts index bc881a81..45ac9302 100644 --- a/src/main/db.ts +++ b/src/main/db.ts @@ -13,6 +13,23 @@ import * as schema from "./db.schema"; let _db: ReturnType | undefined; let _sqlite: InstanceType | undefined; +/** + * Monotonic counter bumped ONLY by writes the profile actually reads — the + * durable usage_events log (dbAppendUsageEvents) and identity edits. The profile + * caches key on this, so high-frequency chat persistence (runtime snapshots/ + * turns) does NOT churn the cache during active sessions. + */ +let _profileDataGeneration = 0; +export function getProfileDataGeneration(): number { + return _profileDataGeneration; +} +export function bumpProfileDataGeneration(): void { + _profileDataGeneration++; +} + +/** How long durable usage events are retained (well beyond the 364-day heatmap). */ +const USAGE_EVENTS_RETENTION_DAYS = 730; + export function initDatabase(dbPath: string) { console.log(`[db] opening ${dbPath}`); const sqlite = new Database(dbPath); @@ -98,11 +115,24 @@ export function initDatabase(dbPath: string) { todos TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + kind TEXT NOT NULL, + provider TEXT, + model TEXT, + mode TEXT, + fast INTEGER NOT NULL DEFAULT 0, + effort TEXT, + name TEXT, + value INTEGER NOT NULL DEFAULT 1 + ); + CREATE INDEX IF NOT EXISTS idx_usage_events_kind ON usage_events (kind); `); // Baseline schema version for future DB migrations. // New upgrade steps should live behind this gate when we need them. - const SCHEMA_VERSION = 16; + const SCHEMA_VERSION = 19; const storedVersion = Number( ( @@ -260,6 +290,24 @@ export function initDatabase(dbPath: string) { `); } + if (storedVersion < 19) { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + kind TEXT NOT NULL, + provider TEXT, + model TEXT, + mode TEXT, + fast INTEGER NOT NULL DEFAULT 0, + effort TEXT, + name TEXT, + value INTEGER NOT NULL DEFAULT 1 + ); + CREATE INDEX IF NOT EXISTS idx_usage_events_kind ON usage_events (kind); + `); + } + sqlite .prepare( "INSERT INTO app_state (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -267,6 +315,16 @@ export function initDatabase(dbPath: string) { .run(String(SCHEMA_VERSION)); } + // Bound the durable usage log: drop events older than the retention window so + // a long-lived install can't accumulate unboundedly (aggregation reads scan + // this table). Runs once per startup; cheap on a bounded table. + try { + const cutoff = Date.now() - USAGE_EVENTS_RETENTION_DAYS * 86_400_000; + sqlite.prepare("DELETE FROM usage_events WHERE ts < ?").run(cutoff); + } catch { + // usage_events may not exist on a partially-migrated db; ignore. + } + console.log("[db] initialized"); return _db; } @@ -713,6 +771,8 @@ function replaceThreadContextUsageInSqlite( sqlite.prepare("DELETE FROM thread_context_usage WHERE thread_id = ?").run(threadId); return; } + // Token usage is captured durably at the canonical-event layer (usage_events), + // not here — this row is only the live context-window snapshot for the UI. sqlite .prepare( `INSERT INTO thread_context_usage (thread_id, usage) VALUES (?, ?) @@ -752,6 +812,100 @@ function safeParse(json: string): unknown { } } +// ── Durable usage-events log (decoupled from thread lifecycle) ────── +// +// Append-only, NO foreign key to threads — so usage stats survive thread delete +// and archive. Provider/model/mode are denormalized at write time (captured at +// the canonical-event layer in the renderer), so aggregation never needs to join +// back to a thread that may no longer exist. + +export interface UsageEventInput { + ts: number; + kind: string; + provider?: string | null | undefined; + model?: string | null | undefined; + mode?: string | null | undefined; + fast?: boolean | undefined; + effort?: string | null | undefined; + name?: string | null | undefined; + value?: number | undefined; +} + +export interface UsageEventRow { + ts: number; + kind: string; + provider: string | null; + model: string | null; + mode: string | null; + fast: boolean; + effort: string | null; + name: string | null; + value: number; +} + +export function dbAppendUsageEvents(events: readonly UsageEventInput[]): void { + if (!_sqlite) throw new Error("Database not initialized"); + if (events.length === 0) return; + const stmt = _sqlite.prepare( + "INSERT INTO usage_events (ts, kind, provider, model, mode, fast, effort, name, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ); + _sqlite.transaction((rows: readonly UsageEventInput[]) => { + for (const e of rows) { + stmt.run( + e.ts, + e.kind, + e.provider ?? null, + e.model ?? null, + e.mode ?? null, + e.fast ? 1 : 0, + e.effort ?? null, + e.name ?? null, + e.value ?? 1, + ); + } + })(events); + bumpProfileDataGeneration(); +} + +// Cache the full-table read keyed on the profile generation so the two readers +// of a single profile open (coreStats + tokenStats) share one scan, and +// repeat opens between writes don't rescan at all. Invalidated implicitly: any +// usage write bumps the generation, so a stale generation never matches. +let _usageEventsCache: { generation: number; rows: UsageEventRow[] } | undefined; + +export function dbGetAllUsageEvents(): UsageEventRow[] { + if (!_sqlite) throw new Error("Database not initialized"); + if (_usageEventsCache && _usageEventsCache.generation === _profileDataGeneration) { + return _usageEventsCache.rows; + } + const rows = _sqlite + .prepare("SELECT ts, kind, provider, model, mode, fast, effort, name, value FROM usage_events") + .all() as Array<{ + ts: number; + kind: string; + provider: string | null; + model: string | null; + mode: string | null; + fast: number; + effort: string | null; + name: string | null; + value: number; + }>; + const mapped: UsageEventRow[] = rows.map((r) => ({ + ts: r.ts, + kind: r.kind, + provider: r.provider, + model: r.model, + mode: r.mode, + fast: r.fast === 1, + effort: r.effort, + name: r.name, + value: r.value, + })); + _usageEventsCache = { generation: _profileDataGeneration, rows: mapped }; + return mapped; +} + export function dbDeleteProject(projectId: string): void { const db = getDb(); db.delete(schema.projects).where(eq(schema.projects.id, projectId)).run(); diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index 98ed0db8..e0954423 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -4,6 +4,7 @@ import { clipboard, dialog, nativeImage, shell, type BrowserWindow } from "elect import type { BrowserPanelManager } from "../browser"; import { openMicrophoneSettings } from "../browser/permissions"; import { + dbAppendUsageEvents, dbDeleteProject, dbDeleteThread, dbGetProjectNotes, @@ -29,6 +30,13 @@ import { saveHandoffContextFile, } from "../attachments/localFiles"; import { createProjectDirectory } from "../projectDirectory"; +import { + getProfileCoreStats, + getProfileDevicesResponse, + getProfileIdentityResponse, + getProfileTokenStats, + setProfileIdentityResponse, +} from "../profile"; import { applyClaudeProfileEnvironment, readSharedSettingsFile, @@ -75,6 +83,15 @@ function getUsageLoginManager( return usageLoginManager; } +function roundRect(rect: { x: number; y: number; width: number; height: number }) { + return { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; +} + const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); function assertSafeExternalUrl(rawUrl: string): string { @@ -325,5 +342,17 @@ export function createLocalIpcHandlers( options.requireLightcodePaths, options.getBrowserPanelManager, ).getLoginState(), + getProfileCoreStats: (req) => getProfileCoreStats(req), + getProfileTokenStats: (req) => getProfileTokenStats(req), + getProfileDevices: () => getProfileDevicesResponse(), + getProfileIdentity: () => getProfileIdentityResponse(), + setProfileIdentity: (identity) => setProfileIdentityResponse(identity), + copyShareImage: async (rect) => { + const win = options.getMainWindow(); + if (!win) return; + const image = await win.webContents.capturePage(roundRect(rect)); + if (!image.isEmpty()) clipboard.writeImage(image); + }, + appendUsageEvents: ({ events }) => dbAppendUsageEvents(events), }); } diff --git a/src/main/profile/coreStats.ts b/src/main/profile/coreStats.ts new file mode 100644 index 00000000..eaf6109a --- /dev/null +++ b/src/main/profile/coreStats.ts @@ -0,0 +1,365 @@ +import { + baseAgentKind, + type AiActionType, + type ProfileAiAction, + type ProfileBreakdownEntry, + type ProfileCoreStats, + type ProfileDevice, + type ProfileInsights, + type ProfileSkillUsage, + type ProfileStatsRequest, + type ProfileTotals, +} from "@/shared/contracts"; +import { dbGetAllUsageEvents, getProfileDataGeneration, type UsageEventRow } from "../db"; +import { buildHeatmap, dayKeyFromIndex, localDayIndex, localHour } from "./heatmap"; +import { getProfileIdentity, recordCurrentDevice, resolveProfileDevice } from "./identity"; +import { accountLabel, providerLabel, titleCase } from "./labels"; + +/** + * Profile core stats, computed entirely from the durable `usage_events` log + * (NOT thread-scoped tables) - so the numbers survive thread delete/archive. + * Every fact was already captured at the canonical-event layer with its + * provider/model/mode denormalized, so there are no joins and no per-provider + * splits here. + */ + +const MAX_SKILLS = 8; + +function hourLabel(hour: number): string { + const period = hour < 12 ? "AM" : "PM"; + const h12 = hour % 12 === 0 ? 12 : hour % 12; + return `${h12} ${period}`; +} + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +function rank( + counts: Map, + labelFor: (key: string) => string, + denominator: number, +): ProfileBreakdownEntry[] { + const entries = [...counts.entries()].map(([key, count]) => ({ + key, + label: labelFor(key), + count, + percent: denominator > 0 ? round1((count / denominator) * 100) : 0, + })); + entries.sort((a, b) => b.count - a.count); + return entries; +} + +function topKey(map: Map): string | undefined { + let bestKey: string | undefined; + let best = -1; + for (const [key, count] of map) { + if (count > best) { + best = count; + bestKey = key; + } + } + return bestKey; +} + +function computeStreaks( + activeDayIndices: Set, + todayIndex: number, +): { current: number; longest: number } { + if (activeDayIndices.size === 0) return { current: 0, longest: 0 }; + const sorted = [...activeDayIndices].sort((a, b) => a - b); + let longest = 1; + let run = 1; + for (let i = 1; i < sorted.length; i++) { + run = sorted[i] === sorted[i - 1]! + 1 ? run + 1 : 1; + if (run > longest) longest = run; + } + let anchor = activeDayIndices.has(todayIndex) + ? todayIndex + : activeDayIndices.has(todayIndex - 1) + ? todayIndex - 1 + : null; + let current = 0; + while (anchor !== null && activeDayIndices.has(anchor)) { + current++; + anchor--; + } + return { current, longest }; +} + +const AI_ACTION_LABELS: Record = { + commit: "AI commits", + pr: "AI pull requests", + conflict: "Conflicts resolved", +}; + +function computeAiActions(rows: UsageEventRow[]): ProfileAiAction[] { + const byType = new Map< + AiActionType, + { count: number; providers: Map; models: Map } + >(); + for (const row of rows) { + if (!row.kind.startsWith("ai_")) continue; + const type = row.kind.slice(3) as AiActionType; + if (type !== "commit" && type !== "pr" && type !== "conflict") continue; + let entry = byType.get(type); + if (!entry) { + entry = { count: 0, providers: new Map(), models: new Map() }; + byType.set(type, entry); + } + entry.count += 1; + if (row.provider) + entry.providers.set(row.provider, (entry.providers.get(row.provider) ?? 0) + 1); + if (row.model) entry.models.set(row.model, (entry.models.get(row.model) ?? 0) + 1); + } + const out: ProfileAiAction[] = []; + for (const type of ["commit", "pr", "conflict"] as AiActionType[]) { + const entry = byType.get(type); + if (!entry) continue; + const topProvider = topKey(entry.providers); + const topModel = topKey(entry.models); + out.push({ + type, + label: AI_ACTION_LABELS[type], + count: entry.count, + ...(topProvider ? { topProvider: providerLabel(baseAgentKind(topProvider)) } : {}), + ...(topModel ? { topModel } : {}), + }); + } + return out; +} + +function computeSkills(rows: UsageEventRow[]): { + skills: ProfileSkillUsage[]; + explored: number; + total: number; + mcps: ProfileSkillUsage[]; +} { + const skillCounts = new Map< + string, + { kind: "skill" | "subagent"; name: string; runCount: number } + >(); + 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 }); + 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()] + .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 }; +} + +// -- Entry point ------------------------------------------------------ + +function emptyTotals(): ProfileTotals { + return { + totalThreads: 0, + totalPrompts: 0, + messagesSent: 0, + goalsSet: 0, + longestTaskMs: 0, + currentStreakDays: 0, + longestStreakDays: 0, + activeDays: 0, + }; +} + +function emptyCoreStats( + device: ProfileDevice, + req: ProfileStatsRequest, + generatedAt: number, + todayIndex: number, +): ProfileCoreStats { + const { heatmap } = buildHeatmap(new Map(), todayIndex, "prompts"); + return { + scope: req.scope ?? "device", + device, + generatedAt, + timezoneOffsetMinutes: req.utcOffsetMinutes, + identity: getProfileIdentity(), + totals: emptyTotals(), + promptHeatmap: heatmap, + insights: { fastModePercent: 0, skillsExplored: 0, totalSkillsUsed: 0 }, + modes: [], + providers: [], + accounts: [], + models: [], + skills: [], + mcps: [], + aiActions: [], + }; +} + +interface CoreCacheEntry { + generation: number; + result: ProfileCoreStats; +} +const coreCache = new Map(); + +export function computeProfileCoreStats(req: ProfileStatsRequest): ProfileCoreStats { + const offset = req.utcOffsetMinutes; + const generatedAt = Date.now(); + const todayIndex = localDayIndex(generatedAt, offset); + + const generation = getProfileDataGeneration(); + const cacheKey = `${offset}|${todayIndex}|${req.scope ?? "device"}|${req.deviceId ?? "current"}`; + const cached = coreCache.get(cacheKey); + if (cached && cached.generation === generation) return cached.result; + + const currentDevice = recordCurrentDevice(); + const targetDeviceId = + req.scope === "all" ? currentDevice.id : (req.deviceId ?? currentDevice.id); + if (targetDeviceId !== currentDevice.id) { + const empty = emptyCoreStats( + resolveProfileDevice(targetDeviceId), + req, + generatedAt, + todayIndex, + ); + coreCache.set(cacheKey, { generation, result: empty }); + return empty; + } + + const rows = dbGetAllUsageEvents(); + + // -- thread starts -> totals + mode breakdown -- + const modeCounts = new Map(); + let totalThreads = 0; + for (const row of rows) { + if (row.kind !== "thread_started") continue; + totalThreads++; + const mode = row.mode === "chat" ? "chat" : "cli"; + modeCounts.set(mode, (modeCounts.get(mode) ?? 0) + 1); + } + const modes = rank(modeCounts, (m) => (m === "chat" ? "Chat" : "CLI"), totalThreads); + + // -- turns -> activity, streaks, breakdowns, longest task -- + const countsByDay = new Map(); + const activeDayIndices = new Set(); + const hourCounts = new Map(); + const providerCounts = new Map(); + const accountCounts = new Map(); + const modelCounts = new Map(); + const effortCounts = new Map(); + let totalTurns = 0; + let longestTaskMs = 0; + let fastTurns = 0; + let effortTurns = 0; + let messagesSent = 0; + let goalsSet = 0; + + for (const row of rows) { + if (row.kind === "message") { + messagesSent++; + continue; + } + if (row.kind === "goal") { + goalsSet++; + continue; + } + if (row.kind !== "turn") continue; + totalTurns++; + if (row.value > longestTaskMs) longestTaskMs = row.value; + const dayIndex = localDayIndex(row.ts, offset); + const day = dayKeyFromIndex(dayIndex); + countsByDay.set(day, (countsByDay.get(day) ?? 0) + 1); + activeDayIndices.add(dayIndex); + const hour = localHour(row.ts, offset); + hourCounts.set(hour, (hourCounts.get(hour) ?? 0) + 1); + if (row.provider) { + const base = baseAgentKind(row.provider); + providerCounts.set(base, (providerCounts.get(base) ?? 0) + 1); + accountCounts.set(row.provider, (accountCounts.get(row.provider) ?? 0) + 1); + } + if (row.model) modelCounts.set(row.model, (modelCounts.get(row.model) ?? 0) + 1); + if (row.fast) fastTurns++; + if (row.effort) { + effortTurns++; + effortCounts.set(row.effort, (effortCounts.get(row.effort) ?? 0) + 1); + } + } + + const { heatmap: promptHeatmap, activeDays } = buildHeatmap(countsByDay, todayIndex, "prompts"); + const { current: currentStreakDays, longest: longestStreakDays } = computeStreaks( + activeDayIndices, + todayIndex, + ); + + const providers = rank(providerCounts, providerLabel, totalTurns); + const accounts = rank(accountCounts, accountLabel, totalTurns); + const models = rank(modelCounts, (m) => m, totalTurns); + const reasoning = rank(effortCounts, (e) => titleCase(e), effortTurns); + + let mostActiveHour: ProfileInsights["mostActiveHour"]; + let bestHourCount = 0; + for (const [hour, count] of hourCounts) { + if (count > bestHourCount) { + bestHourCount = count; + mostActiveHour = { hour, label: hourLabel(hour), count }; + } + } + + const { skills, explored, total: totalSkillsUsed, mcps } = computeSkills(rows); + + const totals: ProfileTotals = { + totalThreads, + totalPrompts: totalTurns, + messagesSent, + goalsSet, + longestTaskMs, + currentStreakDays, + longestStreakDays, + activeDays, + }; + + const insights: ProfileInsights = { + ...(providers[0] ? { topProvider: providers[0] } : {}), + ...(models[0] ? { topModel: models[0] } : {}), + ...(reasoning[0] ? { topReasoning: reasoning[0] } : {}), + fastModePercent: totalTurns > 0 ? round1((fastTurns / totalTurns) * 100) : 0, + ...(mostActiveHour ? { mostActiveHour } : {}), + skillsExplored: explored, + totalSkillsUsed, + }; + + const result: ProfileCoreStats = { + scope: req.scope ?? "device", + device: currentDevice, + generatedAt, + timezoneOffsetMinutes: offset, + identity: getProfileIdentity(), + totals, + promptHeatmap, + insights, + providers, + accounts, + models, + modes, + skills, + mcps, + aiActions: computeAiActions(rows), + }; + coreCache.set(cacheKey, { generation, result }); + return result; +} diff --git a/src/main/profile/heatmap.ts b/src/main/profile/heatmap.ts new file mode 100644 index 00000000..faf8acfe --- /dev/null +++ b/src/main/profile/heatmap.ts @@ -0,0 +1,66 @@ +import type { + ProfileHeatmap, + ProfileHeatmapCell, + ProfileHeatmapIntensity, +} from "@/shared/contracts"; + +/** 52 weeks - a full GitHub-style contribution grid. */ +export const HEATMAP_WINDOW_DAYS = 364; +const DAY_MS = 86_400_000; + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +/** Index of the local calendar day containing `epochMs` (days since epoch). */ +export function localDayIndex(epochMs: number, offsetMin: number): number { + return Math.floor((epochMs + offsetMin * 60_000) / DAY_MS); +} + +/** `YYYY-MM-DD` for a local day index (inverse of {@link localDayIndex}). */ +export function dayKeyFromIndex(idx: number): string { + const d = new Date(idx * DAY_MS); + return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; +} + +export function localHour(epochMs: number, offsetMin: number): number { + return new Date(epochMs + offsetMin * 60_000).getUTCHours(); +} + +function intensityFor(count: number, max: number): ProfileHeatmapIntensity { + if (count <= 0 || max <= 0) return 0; + const ratio = count / max; + if (ratio <= 0.25) return 1; + if (ratio <= 0.5) return 2; + if (ratio <= 0.75) return 3; + return 4; +} + +/** + * Build a fixed 52-week heatmap ending today, with per-day intensity bucketed + * (0-4) against the window max so the renderer stays presentation-only and a + * future Cloud-merged blob renders identically. + */ +export function buildHeatmap( + countsByDay: Map, + todayIndex: number, + metric: ProfileHeatmap["metric"], +): { heatmap: ProfileHeatmap; activeDays: number } { + const startIndex = todayIndex - (HEATMAP_WINDOW_DAYS - 1); + let max = 0; + let activeDays = 0; + const raw: Array<{ day: string; count: number }> = []; + for (let idx = startIndex; idx <= todayIndex; idx++) { + const day = dayKeyFromIndex(idx); + const count = countsByDay.get(day) ?? 0; + if (count > max) max = count; + if (count > 0) activeDays++; + raw.push({ day, count }); + } + const cells: ProfileHeatmapCell[] = raw.map((c) => ({ + day: c.day, + count: c.count, + intensity: intensityFor(c.count, max), + })); + return { heatmap: { metric, windowDays: HEATMAP_WINDOW_DAYS, cells, max }, activeDays }; +} diff --git a/src/main/profile/identity.ts b/src/main/profile/identity.ts new file mode 100644 index 00000000..bd402c96 --- /dev/null +++ b/src/main/profile/identity.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; +import { homedir, hostname, userInfo } from "node:os"; +import { basename } from "node:path"; +import type { ProfileDevice, ProfileIdentity } from "@/shared/contracts"; +import { profileIdentitySchema } from "@/shared/contracts"; +import { bumpProfileDataGeneration, dbGetState, dbSetState } from "../db"; +import { titleCase } from "./labels"; + +/** + * Profile identity + device attribution, persisted in the app_state table. + * + * Both are intentionally device-local: today the identity is a cosmetic override + * and the device id tags this install's stats. When Lightcode Cloud lands, the + * device id is what lets the server merge per-device contributions into the + * "all devices" view while keeping each device individually inspectable. + */ + +const DEVICE_ID_KEY = "profile.deviceId"; +const DEVICES_KEY = "profile.devices"; +const IDENTITY_KEY = "profile.identity"; + +interface StoredDevice { + id: string; + label: string; + platform: string; + firstSeenAt: number; + lastActiveAt: number; +} + +/** Deterministic accent palette so a default avatar color is stable per name. */ +const AVATAR_PALETTE = [ + "oklch(0.62 0.11 245)", // blue (accent) + "oklch(0.6 0.14 295)", // violet + "oklch(0.58 0.15 25)", // red + "oklch(0.6 0.13 150)", // green + "oklch(0.66 0.13 78)", // amber + "oklch(0.6 0.12 200)", // teal +]; + +function hashString(value: string): number { + let h = 0; + for (let i = 0; i < value.length; i++) { + h = (h * 31 + value.charCodeAt(i)) | 0; + } + return Math.abs(h); +} + +function pickAvatarColor(seed: string): string { + return AVATAR_PALETTE[hashString(seed) % AVATAR_PALETTE.length]!; +} + +function slugifyHandle(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "") + .slice(0, 40); + return slug || "you"; +} + +function defaultDisplayName(): string { + try { + const username = userInfo().username?.trim(); + if (username) return titleCase(username); + } catch { + // userInfo can throw on locked-down systems; fall through. + } + const home = basename(homedir()).trim(); + return home ? titleCase(home) : "You"; +} + +function defaultIdentity(): ProfileIdentity { + const name = defaultDisplayName(); + return { + name, + handle: slugifyHandle(name), + avatarColor: pickAvatarColor(name), + plan: "Local", + }; +} + +export function getProfileDevice(): ProfileDevice { + let id = dbGetState(DEVICE_ID_KEY); + if (!id) { + id = randomUUID(); + dbSetState(DEVICE_ID_KEY, id); + } + let label = "This device"; + try { + label = hostname() || label; + } catch { + // hostname can throw in sandboxes; keep the fallback. + } + return { id, label, platform: process.platform }; +} + +function readDeviceRegistry(): Record { + const raw = dbGetState(DEVICES_KEY); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** + * Upsert the current device into the registry with a fresh `lastActiveAt`. + * Called whenever stats are produced so "last active" stays current. The + * registry is the local seed of what Cloud will later populate with the user's + * other devices. + */ +export function recordCurrentDevice(): ProfileDevice { + const device = getProfileDevice(); + const registry = readDeviceRegistry(); + const now = Date.now(); + const existing = registry[device.id]; + registry[device.id] = { + id: device.id, + label: device.label, + platform: device.platform, + firstSeenAt: existing?.firstSeenAt ?? now, + lastActiveAt: now, + }; + dbSetState(DEVICES_KEY, JSON.stringify(registry)); + return { ...device, isCurrent: true, lastActiveAt: now }; +} + +/** All known devices, current first then most-recently-active. */ +export function listProfileDevices(): ProfileDevice[] { + const currentId = getProfileDevice().id; + const registry = readDeviceRegistry(); + if (!registry[currentId]) recordCurrentDevice(); + const devices = Object.values(readDeviceRegistry()).map((d) => ({ + id: d.id, + label: d.label, + platform: d.platform, + isCurrent: d.id === currentId, + lastActiveAt: d.lastActiveAt, + })); + devices.sort((a, b) => { + if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1; + return (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0); + }); + return devices; +} + +/** Look up a device by id from the registry (falls back to the current device). */ +export function resolveProfileDevice(deviceId: string | undefined): ProfileDevice { + const devices = listProfileDevices(); + if (deviceId) { + const match = devices.find((d) => d.id === deviceId); + if (match) return match; + } + return devices.find((d) => d.isCurrent) ?? getProfileDevice(); +} + +export function getProfileIdentity(): ProfileIdentity { + const raw = dbGetState(IDENTITY_KEY); + if (raw) { + try { + const parsed = profileIdentitySchema.safeParse(JSON.parse(raw)); + if (parsed.success) return parsed.data; + } catch { + // corrupt value - fall back to defaults. + } + } + return defaultIdentity(); +} + +export function setProfileIdentity(identity: ProfileIdentity): ProfileIdentity { + const normalized: ProfileIdentity = { + name: identity.name.trim() || defaultDisplayName(), + handle: slugifyHandle(identity.handle || identity.name), + avatarColor: identity.avatarColor.trim() || pickAvatarColor(identity.name), + ...(identity.plan ? { plan: identity.plan } : { plan: "Local" }), + }; + dbSetState(IDENTITY_KEY, JSON.stringify(normalized)); + // Identity is embedded in cached core stats - invalidate it. + bumpProfileDataGeneration(); + return normalized; +} diff --git a/src/main/profile/index.ts b/src/main/profile/index.ts new file mode 100644 index 00000000..075ffa0e --- /dev/null +++ b/src/main/profile/index.ts @@ -0,0 +1,43 @@ +import type { + ProfileCoreStats, + ProfileDevicesResponse, + ProfileIdentity, + ProfileIdentityResponse, + ProfileStatsRequest, + ProfileTokenStats, +} from "@/shared/contracts"; +import { computeProfileCoreStats } from "./coreStats"; +import { + getProfileDevice, + getProfileIdentity, + listProfileDevices, + setProfileIdentity as persistProfileIdentity, +} from "./identity"; +import { computeProfileTokenStats } from "./tokenStats"; + +/** + * Main-process profile facade. Two-tier by design (mirroring how the page + * renders): {@link getProfileCoreStats} is fast (pure SQLite aggregation) so the + * page paints instantly, while {@link getProfileTokenStats} derives the heavier + * token rollups separately so the renderer can keep the same progressive shape. + */ + +export function getProfileCoreStats(req: ProfileStatsRequest): ProfileCoreStats { + return computeProfileCoreStats(req); +} + +export function getProfileTokenStats(req: ProfileStatsRequest): ProfileTokenStats { + return computeProfileTokenStats(req); +} + +export function getProfileDevicesResponse(): ProfileDevicesResponse { + return { devices: listProfileDevices(), currentDeviceId: getProfileDevice().id }; +} + +export function getProfileIdentityResponse(): ProfileIdentityResponse { + return { identity: getProfileIdentity(), device: getProfileDevice() }; +} + +export function setProfileIdentityResponse(identity: ProfileIdentity): ProfileIdentityResponse { + return { identity: persistProfileIdentity(identity), device: getProfileDevice() }; +} diff --git a/src/main/profile/labels.ts b/src/main/profile/labels.ts new file mode 100644 index 00000000..cc4621ba --- /dev/null +++ b/src/main/profile/labels.ts @@ -0,0 +1,40 @@ +import { baseAgentKind } from "@/shared/contracts"; + +/** Display labels for base provider kinds. */ +const PROVIDER_LABELS: Record = { + claude: "Claude", + codex: "Codex", + commandcode: "Command Code", + copilot: "Copilot", + gemini: "Gemini", + grok: "Grok", + cursor: "Cursor", + opencode: "OpenCode", + antigravity: "Antigravity", + "acp-generic": "ACP Agent", +}; + +export function titleCase(value: string): string { + return value + .split(/[\s_:-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +/** Label for a base provider kind (e.g. "claude" -> "Claude"). */ +export function providerLabel(kind: string): string { + return PROVIDER_LABELS[kind] ?? titleCase(kind); +} + +/** + * Label for an account-scoped agent kind. Folds the provider to its base for the + * name and appends the instance/profile id, so multiple accounts of the same + * provider are distinguishable - e.g. "claude:work" -> "Claude - work". + */ +export function accountLabel(agentKind: string): string { + const sep = agentKind.indexOf(":"); + const base = baseAgentKind(agentKind); + const instance = sep > 0 ? agentKind.slice(sep + 1) : ""; + return instance ? `${providerLabel(base)} - ${instance}` : providerLabel(base); +} diff --git a/src/main/profile/tokenStats.ts b/src/main/profile/tokenStats.ts new file mode 100644 index 00000000..9fd59271 --- /dev/null +++ b/src/main/profile/tokenStats.ts @@ -0,0 +1,147 @@ +import { + baseAgentKind, + type ProfileBreakdownEntry, + type ProfileStatsRequest, + type ProfileTokenProvider, + type ProfileTokenStats, +} from "@/shared/contracts"; +import { dbGetAllUsageEvents, getProfileDataGeneration } from "../db"; +import { buildHeatmap, dayKeyFromIndex, HEATMAP_WINDOW_DAYS, localDayIndex } from "./heatmap"; +import { recordCurrentDevice, resolveProfileDevice } from "./identity"; +import { accountLabel, providerLabel } from "./labels"; + +/** + * Token usage from Lightcode's own activity, read from the durable `usage_events` + * log (kind="tokens" - per-turn deltas captured at the canonical-event layer for + * every provider, incl. all ACP agents). No external transcript scanning, and no + * dependency on threads (survives delete/archive). Reported both globally (folded + * to the base provider) and per account (each profile separately). + */ + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +function emptyTokenStats( + device: ProfileTokenStats["device"], + req: ProfileStatsRequest, + nowMs: number, + todayIndex: number, +): ProfileTokenStats { + const { heatmap } = buildHeatmap(new Map(), todayIndex, "tokens"); + return { + available: false, + scope: req.scope ?? "device", + device, + generatedAt: nowMs, + timezoneOffsetMinutes: req.utcOffsetMinutes, + windowDays: HEATMAP_WINDOW_DAYS, + lifetimeTokens: 0, + peakDayTokens: 0, + providers: [], + accounts: [], + models: [], + tokenHeatmap: heatmap, + }; +} + +const tokenCache = new Map(); + +export function computeProfileTokenStats(req: ProfileStatsRequest): ProfileTokenStats { + const offset = req.utcOffsetMinutes; + const nowMs = Date.now(); + const todayIndex = localDayIndex(nowMs, offset); + + // 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 cached = tokenCache.get(cacheKey); + if (cached && cached.generation === generation) return cached.result; + + const currentDevice = recordCurrentDevice(); + const targetDeviceId = + req.scope === "all" ? currentDevice.id : (req.deviceId ?? currentDevice.id); + if (targetDeviceId !== currentDevice.id) { + const empty = emptyTokenStats(resolveProfileDevice(targetDeviceId), req, nowMs, todayIndex); + tokenCache.set(cacheKey, { generation, result: empty }); + return empty; + } + + const perDay = new Map(); + const perProvider = new Map(); + const perAccount = new Map(); + const perModel = new Map(); + let lifetimeTokens = 0; + + for (const row of dbGetAllUsageEvents()) { + if (row.kind !== "tokens" || row.value <= 0) continue; + lifetimeTokens += row.value; + const day = dayKeyFromIndex(localDayIndex(row.ts, offset)); + perDay.set(day, (perDay.get(day) ?? 0) + row.value); + if (row.provider) { + const base = baseAgentKind(row.provider); + perProvider.set(base, (perProvider.get(base) ?? 0) + row.value); + perAccount.set(row.provider, (perAccount.get(row.provider) ?? 0) + row.value); + } + if (row.model) perModel.set(row.model, (perModel.get(row.model) ?? 0) + row.value); + } + + const { heatmap: tokenHeatmap } = buildHeatmap(perDay, todayIndex, "tokens"); + + let peakDay: string | undefined; + let peakDayTokens = 0; + for (const [day, tokens] of perDay) { + if (tokens > peakDayTokens) { + peakDayTokens = tokens; + peakDay = day; + } + } + + const providers: ProfileTokenProvider[] = [...perProvider.entries()] + .filter(([, tokens]) => tokens > 0) + .map(([provider, tokens]) => ({ + provider, + label: providerLabel(provider), + tokens, + percent: lifetimeTokens > 0 ? round1((tokens / lifetimeTokens) * 100) : 0, + })) + .sort((a, b) => b.tokens - a.tokens); + + const accounts: ProfileTokenProvider[] = [...perAccount.entries()] + .filter(([, tokens]) => tokens > 0) + .map(([account, tokens]) => ({ + provider: account, + label: accountLabel(account), + tokens, + percent: lifetimeTokens > 0 ? round1((tokens / lifetimeTokens) * 100) : 0, + })) + .sort((a, b) => b.tokens - a.tokens); + + const models: ProfileBreakdownEntry[] = [...perModel.entries()] + .map(([model, tokens]) => ({ + key: model, + label: model, + count: tokens, + percent: lifetimeTokens > 0 ? round1((tokens / lifetimeTokens) * 100) : 0, + })) + .sort((a, b) => b.count - a.count); + + const result: ProfileTokenStats = { + available: lifetimeTokens > 0, + scope: req.scope ?? "device", + device: currentDevice, + generatedAt: nowMs, + timezoneOffsetMinutes: offset, + windowDays: HEATMAP_WINDOW_DAYS, + lifetimeTokens, + peakDayTokens, + ...(peakDay ? { peakDay } : {}), + providers, + accounts, + models, + tokenHeatmap, + }; + tokenCache.set(cacheKey, { generation, result }); + return result; +} diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index bcd44386..bf5c8358 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -10,6 +10,7 @@ import { } from "./notifications"; import { useAppStore } from "./state/appStore"; +import { recordRuntimeUsage } from "./state/usageRecorder"; import { useDevTerminalStore } from "./state/devTerminalStore"; import { useAgentStatusesStore } from "./state/agentStatusesStore"; import { useProviderUsageStore } from "./state/providerUsageStore"; @@ -54,8 +55,12 @@ function flushPendingRuntimeEvents(): void { runtimeFlushHandle = null; if (pendingRuntimeEvents.size === 0) return; const store = useAppStore.getState(); + const threads = store.threads; for (const [threadId, events] of pendingRuntimeEvents) { store.applyRuntimeEvents(threadId, events); + // Durable usage capture at the canonical layer (all providers normalized). + // Thread metadata is resolved lazily inside, so pure-delta frames are free. + recordRuntimeUsage(threadId, events, threads); } pendingRuntimeEvents.clear(); } diff --git a/src/renderer/components/providers/commitGen.test.ts b/src/renderer/components/providers/commitGen.test.ts index e06a9027..d17bb449 100644 --- a/src/renderer/components/providers/commitGen.test.ts +++ b/src/renderer/components/providers/commitGen.test.ts @@ -3,6 +3,7 @@ import type { AgentStatus, GenerateCommitMessagePayload } from "@/shared/contrac import { getCommitGenDefaultsHint } from "./ProviderIcon"; import { generateCommitMessageWithFallback, + generateCommitMessageWithFallbackDetails, getCommitGenCandidates, resolveCommitGenConfig, } from "./commitGen"; @@ -171,6 +172,27 @@ describe("generateCommitMessageWithFallback", () => { }); }); + it("returns the provider and model that actually generated the message", async () => { + const invoke = vi.fn(); + invoke.mockRejectedValueOnce(new Error("Codex CLI not found: codex")); + invoke.mockResolvedValueOnce({ message: "fix(git): restore commit generation" }); + + await expect( + generateCommitMessageWithFallbackDetails({ + projectLocation, + agentStatuses: [codexStatus, claudeStatus], + provider: "auto", + model: "", + effort: "", + invoke, + }), + ).resolves.toEqual({ + message: "fix(git): restore commit generation", + provider: "claude", + model: "sonnet", + }); + }); + it("does not fall back when a specific provider is selected", async () => { const invoke = vi.fn(); invoke.mockRejectedValueOnce(new Error("Codex CLI not found: codex")); diff --git a/src/renderer/components/providers/commitGen.ts b/src/renderer/components/providers/commitGen.ts index bbe90a88..7e2cecb5 100644 --- a/src/renderer/components/providers/commitGen.ts +++ b/src/renderer/components/providers/commitGen.ts @@ -33,14 +33,24 @@ export function getCommitGenCandidates( return getUtilityTaskCandidates(agentStatuses, provider, getCommitGenDefaults); } -export async function generateCommitMessageWithFallback(input: { +interface GenerateCommitMessageWithFallbackInput { projectLocation: ProjectLocation; agentStatuses: readonly AgentStatus[]; provider: string; model: string; effort: string; invoke: (payload: GenerateCommitMessagePayload) => Promise; -}): Promise { +} + +export interface GeneratedCommitMessageWithProvider { + message: string; + provider: string; + model: string; +} + +export async function generateCommitMessageWithFallbackDetails( + input: GenerateCommitMessageWithFallbackInput, +): Promise { const candidates = getCommitGenCandidates(input.agentStatuses, input.provider); if (candidates.length === 0) { throw new Error("No agent available to generate commit message"); @@ -58,7 +68,11 @@ export async function generateCommitMessageWithFallback(input: { ...(resolvedCommitGen.model ? { model: resolvedCommitGen.model } : {}), ...(resolvedCommitGen.effort ? { effort: resolvedCommitGen.effort } : {}), }); - return result.message; + return { + message: result.message, + provider: candidate.kind, + model: resolvedCommitGen.model || "default", + }; } catch (error) { const message = toErrorMessage(error); if (input.provider !== "auto") { @@ -70,3 +84,9 @@ export async function generateCommitMessageWithFallback(input: { throw new Error(`Auto commit generation failed. ${failures.join(" | ")}`); } + +export async function generateCommitMessageWithFallback( + input: GenerateCommitMessageWithFallbackInput, +): Promise { + return (await generateCommitMessageWithFallbackDetails(input)).message; +} diff --git a/src/renderer/state/gitReviewActionStore.ts b/src/renderer/state/gitReviewActionStore.ts index 41d4c3eb..eab20631 100644 --- a/src/renderer/state/gitReviewActionStore.ts +++ b/src/renderer/state/gitReviewActionStore.ts @@ -23,13 +23,31 @@ import { create } from "zustand"; * survive an app restart, or an action killed with the process would leave its * spinner stuck on forever. */ +/** + * Provenance of an AI-generated draft, kept alongside the draft text so a + * later commit/PR that uses it is attributed to the right provider/model even + * when the user pressed "Generate" explicitly (which fills the draft, so the + * commit/PR code path sees a non-empty field and skips its inline-generate + * branch). `text` is the exact generated string, matched against the final + * value to confirm the AI draft actually survived to the action. + */ +export interface GeneratedDraftMeta { + text: string; + provider: string; + model: string; +} + export interface GitReviewActionState { /** Draft commit message — typed by the user or filled in by generation. */ commitMessage: string; + /** Provenance of the last AI-generated commit message (null once consumed/replaced). */ + commitGen: GeneratedDraftMeta | null; /** Draft PR title. */ prTitle: string; /** Draft PR body. */ prBody: string; + /** Provenance of the last AI-generated PR summary (matched on title). */ + prGen: GeneratedDraftMeta | null; /** Draft PR target branch (null = use the resolved source branch). */ prTargetBranch: string | null; /** A commit-message generation is in flight (supervisor one-shot LLM call). */ @@ -55,8 +73,10 @@ export interface GitReviewActionState { /** Stable default returned for panels with no state yet — never mutate. */ const EMPTY_STATE: GitReviewActionState = Object.freeze({ commitMessage: "", + commitGen: null, prTitle: "", prBody: "", + prGen: null, prTargetBranch: null, isGenerating: false, isGeneratingPr: false, diff --git a/src/renderer/state/slices/threadSlice.ts b/src/renderer/state/slices/threadSlice.ts index 81bf6337..49c4b61d 100644 --- a/src/renderer/state/slices/threadSlice.ts +++ b/src/renderer/state/slices/threadSlice.ts @@ -21,6 +21,7 @@ import { } from "../reorder"; import { makeThreadTitle, removePaneFromView, replacePaneInView, stripPlanMode } from "./helpers"; import { markThreadRuntimeForPersistence, type CompletedTurnRecord } from "./runtimeEventSlice"; +import { recordThreadStarted } from "../usageRecorder"; import type { AppStoreState, SliceCreator } from "./shared"; export interface ThreadSlice { @@ -283,6 +284,8 @@ export const createThreadSlice: SliceCreator = (set) => ({ }; }); + // Durable "thread started" usage fact (survives later delete/archive). + recordThreadStarted(thread); return thread; }, deleteThread: (threadId) => diff --git a/src/renderer/state/usageRecorder.test.ts b/src/renderer/state/usageRecorder.test.ts new file mode 100644 index 00000000..95575b0a --- /dev/null +++ b/src/renderer/state/usageRecorder.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEvent, Thread, UsageEventInputPayload } from "@/shared/contracts"; + +const bridgeMock = vi.hoisted(() => ({ + appendUsageEvents: vi.fn<(payload: { events: UsageEventInputPayload[] }) => Promise>(), +})); + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => bridgeMock, +})); + +import { recordRuntimeUsage, recordThreadStarted } from "./usageRecorder"; + +function makeThread(id: string, agentKind: string): Thread { + return { + id, + agentKind, + config: { model: "test-model" }, + presentationMode: "gui", + } as unknown as Thread; +} + +function contextUpdated(threadId: string, usedTokens: number): RuntimeEvent { + return { type: "context.updated", threadId, usage: { usedTokens } }; +} + +// The recorder flushes its buffer synchronously on `pagehide`; dispatch it to +// drain without waiting on the idle/timeout debounce. +function flushNow(): void { + window.dispatchEvent(new Event("pagehide")); +} + +function emittedTokenValues(provider: string): number[] { + return bridgeMock.appendUsageEvents.mock.calls + .flatMap((call) => call[0].events) + .filter((event) => event.kind === "tokens" && event.provider === provider) + .map((event) => event.value ?? 0); +} + +describe("usageRecorder token baseline", () => { + beforeEach(() => { + flushNow(); // drain anything a prior test left buffered + bridgeMock.appendUsageEvents.mockReset(); + bridgeMock.appendUsageEvents.mockResolvedValue(undefined); + }); + afterEach(() => { + flushNow(); + }); + + it("does not re-count a resumed thread's restored context, but counts later growth", () => { + const provider = "resumed-provider"; + const thread = makeThread("resumed-thread", provider); + // Resumed thread: recordThreadStarted was NOT called this session, so there + // is no seeded baseline. Its first context.updated reports the restored + // context (already counted in a prior session) and must emit nothing. + recordRuntimeUsage("resumed-thread", [contextUpdated("resumed-thread", 50_000)], [thread]); + flushNow(); + expect(emittedTokenValues(provider)).toEqual([]); + + // A later context.updated reflects genuine growth and IS counted. + recordRuntimeUsage("resumed-thread", [contextUpdated("resumed-thread", 50_500)], [thread]); + flushNow(); + expect(emittedTokenValues(provider)).toEqual([500]); + }); + + it("counts the full initial context for a thread started this session", () => { + const provider = "new-provider"; + const thread = makeThread("new-thread", provider); + // recordThreadStarted seeds the baseline to 0, so the first context.updated + // counts the whole new context as a delta from zero. + recordThreadStarted(thread); + recordRuntimeUsage("new-thread", [contextUpdated("new-thread", 1_200)], [thread]); + flushNow(); + expect(emittedTokenValues(provider)).toEqual([1_200]); + }); +}); diff --git a/src/renderer/state/usageRecorder.ts b/src/renderer/state/usageRecorder.ts new file mode 100644 index 00000000..6f581344 --- /dev/null +++ b/src/renderer/state/usageRecorder.ts @@ -0,0 +1,323 @@ +import { + type AiActionType, + type RuntimeEvent, + type Thread, + type UsageEventInputPayload, +} from "@/shared/contracts"; +import { readBridge } from "@/renderer/bridge"; + +/** + * Durable usage capture at the canonical-event layer. + * + * Every provider's runtime events are already normalized into the canonical + * `RuntimeEvent` stream before they reach here, so this is the single place we + * derive usage facts - no per-provider splits. Each fact is written to the + * durable `usage_events` log (no thread FK), with provider/model/mode embedded, + * so the stats survive thread delete/archive. + * + * Writes are buffered and flushed on a debounce (fire-and-forget IPC) so the hot + * event path is never blocked. This module intentionally does NOT import the app + * store (callers pass the Thread) to avoid an import cycle with the slices. + */ + +// Flush during renderer idle so the write never competes with an active frame; +// the timeout caps latency. A hard buffer cap forces an early flush so a delayed +// idle slot can't let the buffer grow unbounded. +const FLUSH_IDLE_TIMEOUT_MS = 2000; +const MAX_BUFFER = 1000; +// Cap the dedup sets so a marathon session can't grow them without bound. The +// window only needs to span an optimistic-render + supervisor-echo of the same +// id (milliseconds apart), so a periodic reset is harmless. +const DEDUP_CAP = 20000; + +let buffer: UsageEventInputPayload[] = []; +let scheduled: { cancel: () => void } | null = null; +const turnStartByThread = new Map(); +// Cumulative usedTokens per thread, used for the LAG delta. Intentionally NOT +// cleared per turn: usedTokens is cumulative across a thread's whole life, so +// resetting it would make the next delta count the full context again. One int +// per thread that streamed tokens this session - cleared on app restart. +// +// Presence (`.has`), not just value, carries meaning. A thread created THIS +// session is seeded to 0 by recordThreadStarted, so its first context.updated +// counts the whole new context (delta from 0). A thread RESUMED from a prior +// session has no entry: its first context.updated reports a context whose +// tokens were already counted last session, so we only establish the baseline +// and emit nothing - otherwise the restored context would be double-counted on +// every restart, inflating lifetime/peak. See the context.updated case below. +const lastUsedByThread = new Map(); +// Token deltas coalesced per provider|model between flushes, so a turn that +// streams many context.updated events produces ONE row, not dozens. +const pendingTokens = new Map< + string, + { provider: string | null; model: string | null; value: number } +>(); +const seenItems = new Set(); +const seenTurns = new Set(); + +function flush(): void { + if (scheduled) { + scheduled.cancel(); + scheduled = null; + } + if (pendingTokens.size > 0) { + const ts = Date.now(); + for (const t of pendingTokens.values()) { + buffer.push({ ts, kind: "tokens", provider: t.provider, model: t.model, value: t.value }); + } + pendingTokens.clear(); + } + if (buffer.length === 0) return; + const events = buffer; + buffer = []; + void readBridge() + .appendUsageEvents({ events }) + .catch(() => undefined); +} + +function scheduleFlush(): void { + if (scheduled) return; + if (typeof requestIdleCallback === "function") { + const id = requestIdleCallback( + () => { + scheduled = null; + flush(); + }, + { timeout: FLUSH_IDLE_TIMEOUT_MS }, + ); + scheduled = { cancel: () => cancelIdleCallback(id) }; + } else { + const id = setTimeout(() => { + scheduled = null; + flush(); + }, FLUSH_IDLE_TIMEOUT_MS); + scheduled = { cancel: () => clearTimeout(id) }; + } +} + +function push(event: UsageEventInputPayload): void { + buffer.push(event); + if (buffer.length >= MAX_BUFFER) flush(); + else scheduleFlush(); +} + +/** Add to a bounded dedup set; returns false if already seen. */ +function remember(set: Set, id: string): boolean { + if (set.has(id)) return false; + if (set.size >= DEDUP_CAP) set.clear(); + set.add(id); + return true; +} + +// Don't lose buffered events when the window is hidden or closed. +if (typeof window !== "undefined") { + window.addEventListener("pagehide", flush); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") flush(); + }); +} + +interface Meta { + provider: string; + model: string | null; + mode: string; + fast: boolean; + effort: string | null; +} + +function metaOf(thread: Thread): Meta { + return { + // Full account-scoped kind (e.g. "claude:work"), so usage of different + // profiles of the same provider is counted separately. The global rollup + // folds to the base provider at read time. + provider: thread.agentKind, + model: thread.config.model ?? null, + mode: thread.presentationMode === "gui" ? "chat" : "cli", + fast: thread.config.fast === true, + effort: thread.config.effort ?? null, + }; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} +function str(obj: Record | undefined, key: string): string | undefined { + const v = obj?.[key]; + return typeof v === "string" && v.trim() ? v.trim() : undefined; +} + +interface ItemHit { + kind: "message" | "goal" | "skill" | "subagent" | "mcp"; + name?: string; +} + +function classifyItem(itemType: string, payload: unknown): ItemHit | undefined { + if (itemType === "user_message") return { kind: "message" }; + const p = asRecord(payload); + if (itemType === "goal") { + return str(p, "action") === "set" ? { kind: "goal" } : undefined; + } + if ( + itemType !== "tool_call" && + itemType !== "dynamic_tool_call" && + itemType !== "mcp_tool_call" + ) { + return undefined; + } + const name = str(p, "name") ?? ""; + const title = str(p, "title") ?? ""; + const args = asRecord(p?.["args"]); + + if (itemType === "mcp_tool_call" || /^mcp__/.test(name)) { + const match = /^mcp__(.+?)__/.exec(name); + return { kind: "mcp", name: match?.[1] ?? str(p, "serverId") ?? "mcp" }; + } + if ( + name === "Skill" || + /^(loaded|using) skill\b/i.test(name) || + /^(loaded|using) skill\b/i.test(title) + ) { + const skill = + str(args, "skill") ?? + str(args, "name") ?? + title + .replace(/^(loaded|using) skill[:\s]*/i, "") + .replace(/^skill:\s*/i, "") + .trim(); + return { kind: "skill", name: skill || "skill" }; + } + const subagentType = str(args, "subagent_type"); + if (p?.["isSubAgent"] === true || name === "Task" || name === "Workflow" || subagentType) { + const agent = + subagentType ?? (name === "Workflow" ? "workflow" : str(args, "description")) ?? "subagent"; + return { kind: "subagent", name: agent }; + } + return undefined; +} + +/** Record an AI-performed git action (commit / PR / conflict) into the buffer. */ +export function recordAiAction(type: AiActionType, provider: string, model: string): void { + push({ ts: Date.now(), kind: `ai_${type}`, provider, model, value: 1 }); +} + +/** Record that a thread was started (provider/model/mode/fast/effort). */ +export function recordThreadStarted(thread: Thread): void { + const m = metaOf(thread); + // Seed the token baseline so this freshly-started thread's first + // context.updated counts its initial context as new (delta from 0). Resumed + // threads get no seed and are handled in the context.updated case below. + if (!lastUsedByThread.has(thread.id)) lastUsedByThread.set(thread.id, 0); + push({ + ts: Date.now(), + kind: "thread_started", + provider: m.provider, + model: m.model, + mode: m.mode, + fast: m.fast, + effort: m.effort, + value: 1, + }); +} + +/** + * Derive durable usage events from a batch of canonical runtime events. Thread + * metadata is resolved lazily so a pure `content.delta` frame (the streaming + * common case) does no thread lookup and no allocation at all. + */ +export function recordRuntimeUsage( + threadId: string, + events: readonly RuntimeEvent[], + threads: readonly Thread[], +): void { + let metaResolved = false; + let meta: Meta | null = null; + const getMeta = (): Meta | null => { + if (!metaResolved) { + metaResolved = true; + const thread = threads.find((t) => t.id === threadId); + meta = thread ? metaOf(thread) : null; + } + return meta; + }; + + const now = Date.now(); + let tokensTouched = false; + + for (const event of events) { + switch (event.type) { + case "turn.started": + if (turnStartByThread.get(threadId)?.turnId !== event.turnId) { + turnStartByThread.set(threadId, { turnId: event.turnId, startedAt: now }); + } + break; + case "turn.completed": { + if (event.state === "completed" && remember(seenTurns, event.turnId)) { + const m = getMeta(); + if (m) { + const start = turnStartByThread.get(threadId); + const startedAt = start?.turnId === event.turnId ? start.startedAt : now; + push({ + ts: now, + kind: "turn", + provider: m.provider, + model: m.model, + mode: m.mode, + fast: m.fast, + effort: m.effort, + value: Math.max(0, now - startedAt), + }); + } + } + turnStartByThread.delete(threadId); + break; + } + case "context.updated": { + const used = event.usage.usedTokens; + if (typeof used === "number" && used > 0) { + // No baseline means this thread was resumed from a prior session: its + // usedTokens already reflects context counted then, so only establish + // the baseline and emit nothing. Counting delta-from-zero here would + // re-add the whole restored context. Threads started this session are + // seeded to 0 by recordThreadStarted, so they DO count their first + // context. + const hasBaseline = lastUsedByThread.has(threadId); + const prev = lastUsedByThread.get(threadId) ?? 0; + lastUsedByThread.set(threadId, used); + const delta = hasBaseline ? Math.max(0, used - prev) : 0; + if (delta > 0) { + const m = getMeta(); + if (m) { + const key = `${m.provider}|${m.model ?? ""}`; + const entry = pendingTokens.get(key); + if (entry) entry.value += delta; + else pendingTokens.set(key, { provider: m.provider, model: m.model, value: delta }); + tokensTouched = true; + } + } + } + break; + } + case "item.started": { + const hit = classifyItem(event.itemType, event.payload); + if (!hit) break; + if (!remember(seenItems, event.itemId)) break; + const m = getMeta(); + if (!m) break; + push({ + ts: now, + kind: hit.kind, + provider: m.provider, + model: m.model, + mode: m.mode, + ...(hit.name ? { name: hit.name } : {}), + value: 1, + }); + break; + } + default: + break; + } + } + + if (tokensTouched) scheduleFlush(); +} diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts index 256eb846..f2a0d7df 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts @@ -6,6 +6,7 @@ import { readConflictResolverSettingsForProject, resolveConflictResolverLaunchConfig, } from "@/renderer/components/providers/conflictResolver"; +import { recordAiAction } from "@/renderer/state/usageRecorder"; import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; import { useAppStore } from "@/renderer/state/appStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; @@ -111,6 +112,7 @@ export function useConflictResolver(params: { ...(worktreeBranch ? { worktreeBranch } : {}), }); store.queueThreadLaunch(thread.id, prompt); + recordAiAction("conflict", provider.kind, model || "default"); } return { canResolveWithAgent, handleResolveWithAgent, projectAgentStatuses }; diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts index 5510d2ac..fd33e71e 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts @@ -14,10 +14,12 @@ import { useGitStore } from "@/renderer/state/gitStore"; import { startPostPushPrStatusRefresh } from "@/renderer/state/gitRefresh"; import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { recordAiAction } from "@/renderer/state/usageRecorder"; import { - generateCommitMessageWithFallback, + generateCommitMessageWithFallbackDetails, getCommitGenCandidates, resolveCommitGenConfig, + type GeneratedCommitMessageWithProvider, } from "@/renderer/components/providers"; import { usePrWriteActions } from "@/renderer/hooks/usePrWriteActions"; import { @@ -112,8 +114,10 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { // gitReviewActionStore. const { commitMessage, + commitGen, prTitle, prBody, + prGen, prTargetBranch, isGenerating, isGeneratingPr, @@ -172,8 +176,8 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { }); } - async function generateMessage(): Promise { - return generateCommitMessageWithFallback({ + async function generateMessage(): Promise { + return generateCommitMessageWithFallbackDetails({ projectLocation: project.location, agentStatuses: projectAgentStatuses, provider: commitGenProvider, @@ -192,11 +196,14 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { try { let message = commitMessage.trim(); let autoGeneratedMessage = false; + let commitGenResult: GeneratedCommitMessageWithProvider | undefined; if (!message && canGenerateMessage) { setIsGenerating(true); try { - message = await generateMessage(); + const generated = await generateMessage(); + message = generated.message; autoGeneratedMessage = true; + commitGenResult = generated; setCommitMessage(message); } finally { setIsGenerating(false); @@ -216,7 +223,15 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { has_worktree: Boolean(worktreePath), push_after: pushAfter, }); - setCommitMessage(""); + // Attribute the commit to AI when it used a generated message — generated + // inline just now (empty field) or earlier via the explicit Generate + // button (commitGen, matched against the committed text). + if (autoGeneratedMessage && commitGenResult) { + recordAiAction("commit", commitGenResult.provider, commitGenResult.model); + } else if (commitGen && commitGen.text.trim() === message) { + recordAiAction("commit", commitGen.provider, commitGen.model); + } + patch(storeKey, { commitMessage: "", commitGen: null }); // The new commit makes us one ahead and the staged set is now part of // the commit. Reflect that in the store immediately so the push button // appears without waiting for a `git status` round-trip. @@ -278,12 +293,22 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { if (isGenerating) return; setIsGenerating(true); try { - const message = await generateMessage(); - setCommitMessage(message); + const generated = await generateMessage(); + // Keep the draft's provider/model with the text, so a later commit using + // this generated message is still attributed to AI even though + // handleCommit's inline-generate branch is skipped (field is non-empty). + patch(storeKey, { + commitMessage: generated.message, + commitGen: { + text: generated.message, + provider: generated.provider, + model: generated.model, + }, + }); captureProductEvent("git.commit_message_generated", { effort: commitGenEffort || "default", has_worktree: Boolean(worktreePath), - provider: commitGenProvider, + provider: generated.provider, }); } catch (err) { console.error("[git] generate message failed", err); @@ -473,7 +498,7 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { candidates: ReturnType, headBranch: string, baseBranch: string, - ): Promise<{ title: string; description: string } | null> { + ): Promise<{ title: string; description: string; provider: string; model: string } | null> { for (const candidate of candidates) { const resolved = resolveCommitGenConfig(candidate, commitGenModel, commitGenEffort); try { @@ -490,7 +515,7 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { has_worktree: Boolean(worktreePath), provider: candidate.kind, }); - return result; + return { ...result, provider: candidate.kind, model: resolved.model || "default" }; } catch (err) { // With a fixed provider there's nothing to fall back to — let the // caller decide how to surface it. With "auto", try the next candidate. @@ -508,6 +533,8 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { let title = prTitle.trim(); let body = prBody.trim(); let autoGenerated = false; + let summaryProvider: string | undefined; + let summaryModel: string | undefined; // No title entered: auto-generate the summary first, mirroring the // empty-commit-message flow in handleCommit. This powers both the // "auto" create-PR mode and pressing Create in the dialog with a blank @@ -521,6 +548,8 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { title = summary.title.trim(); body = summary.description.trim(); autoGenerated = true; + summaryProvider = summary.provider; + summaryModel = summary.model; setPrTitle(title); setPrBody(body); } @@ -541,11 +570,20 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { has_worktree: Boolean(worktreePath), is_draft: isDraft, }); + // Attribute the PR to AI when its summary was generated — inline just now + // (empty title) or earlier via the explicit Generate button (prGen, + // matched against the submitted title). + if (autoGenerated && summaryProvider) { + recordAiAction("pr", summaryProvider, summaryModel || "default"); + } else if (prGen && title && prGen.text.trim() === title) { + // `title` is guarded non-empty: the PR falls back to the branch name + // when blank, so an (unlikely) empty generated title must not match. + recordAiAction("pr", prGen.provider, prGen.model); + } if (effectivePrKey) { useGitStore.getState().setPrData(effectivePrKey, pr); } - setPrTitle(""); - setPrBody(""); + patch(storeKey, { prTitle: "", prBody: "", prGen: null }); } catch (err) { console.error("[git] create PR failed", err); toast.danger(friendlyError(err)); @@ -581,8 +619,14 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { try { const result = await generatePrSummaryResult(candidates, effectiveBranch, targetBranch); if (result) { - setPrTitle(result.title); - setPrBody(result.description); + // Keep the summary's provider/model with the draft, so creating the PR + // later attributes it to AI even though handleCreatePr's inline-generate + // branch is skipped (title is non-empty). + patch(storeKey, { + prTitle: result.title, + prBody: result.description, + prGen: { text: result.title, provider: result.provider, model: result.model }, + }); } } catch (err) { console.error("[git] generate PR summary failed", err); diff --git a/src/renderer/views/ProfileOverlay/format.ts b/src/renderer/views/ProfileOverlay/format.ts new file mode 100644 index 00000000..1ed94ea9 --- /dev/null +++ b/src/renderer/views/ProfileOverlay/format.ts @@ -0,0 +1,56 @@ +/** Compact number formatting matching the design refs (e.g. "4.9B", "290.3M", "1.2K"). */ +export function formatCompact(value: number): string { + if (!Number.isFinite(value) || value <= 0) return "0"; + const units: Array<{ limit: number; suffix: string }> = [ + { limit: 1e12, suffix: "T" }, + { limit: 1e9, suffix: "B" }, + { limit: 1e6, suffix: "M" }, + { limit: 1e3, suffix: "K" }, + ]; + for (const { limit, suffix } of units) { + if (value >= limit) { + const scaled = value / limit; + const text = scaled >= 100 ? scaled.toFixed(0) : scaled.toFixed(1); + return `${text.replace(/\.0$/, "")}${suffix}`; + } + } + return String(Math.round(value)); +} + +/** Human task duration, e.g. "1h 27m", "5m 3s", "42s". */ +export function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return "-"; + const totalSeconds = Math.round(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +/** "7 days", "1 day", "0 days". */ +export function formatDays(n: number): string { + return `${n} ${n === 1 ? "day" : "days"}`; +} + +/** "123 runs", "1 run". */ +export function formatRuns(n: number): string { + return `${n.toLocaleString()} ${n === 1 ? "run" : "runs"}`; +} + +/** Short, friendly day label from a `YYYY-MM-DD` key (local, no TZ math). */ +export function formatDayLabel(day: string): string { + const [y, m, d] = day.split("-").map((p) => Number.parseInt(p, 10)); + if (!y || !m || !d) return day; + const date = new Date(Date.UTC(y, m - 1, d)); + return date.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" }); +} + +/** First letters of up to two name parts, uppercased. */ +export function initialsFor(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); + return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase(); +} diff --git a/src/renderer/views/ProfileOverlay/parts/ActivityHeatmap.tsx b/src/renderer/views/ProfileOverlay/parts/ActivityHeatmap.tsx new file mode 100644 index 00000000..b01505d3 --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/ActivityHeatmap.tsx @@ -0,0 +1,119 @@ +import type { + ProfileHeatmap, + ProfileHeatmapCell, + ProfileHeatmapIntensity, +} from "@/shared/contracts"; +import { formatCompact, formatDayLabel } from "../format"; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** Monochrome ramp built from the theme foreground (white in dark, black in light). */ +function colorFor(intensity: ProfileHeatmapIntensity): string { + switch (intensity) { + case 0: + return "color-mix(in oklab, var(--foreground) 8%, transparent)"; + case 1: + return "color-mix(in oklab, var(--foreground) 32%, transparent)"; + case 2: + return "color-mix(in oklab, var(--foreground) 55%, transparent)"; + case 3: + return "color-mix(in oklab, var(--foreground) 78%, transparent)"; + case 4: + return "var(--foreground)"; + } +} + +function weekdayOf(day: string): number { + const [y, m, d] = day.split("-").map((p) => Number.parseInt(p, 10)); + return new Date(Date.UTC(y!, (m ?? 1) - 1, d ?? 1)).getUTCDay(); +} + +function monthOf(day: string): number { + return Number.parseInt(day.split("-")[1] ?? "1", 10) - 1; +} + +function tooltipFor(cell: ProfileHeatmapCell, metric: ProfileHeatmap["metric"]): string { + const when = formatDayLabel(cell.day); + if (metric === "tokens") return `${formatCompact(cell.count)} tokens - ${when}`; + return `${cell.count} ${cell.count === 1 ? "prompt" : "prompts"} - ${when}`; +} + +function buildColumns(cells: readonly ProfileHeatmapCell[]): { + columns: Array>; + monthLabels: Array; +} { + const grid: (ProfileHeatmapCell | null)[] = []; + if (cells.length > 0) { + const lead = weekdayOf(cells[0]!.day); + for (let i = 0; i < lead; i++) grid.push(null); + } + grid.push(...cells); + while (grid.length % 7 !== 0) grid.push(null); + + const columns: Array> = []; + for (let i = 0; i < grid.length; i += 7) columns.push(grid.slice(i, i + 7)); + + const monthLabels: Array = []; + let prevMonth = -1; + for (const col of columns) { + const first = col.find((c): c is ProfileHeatmapCell => c !== null); + if (!first) { + monthLabels.push(null); + continue; + } + const month = monthOf(first.day); + if (month !== prevMonth) { + monthLabels.push(MONTHS[month]!); + prevMonth = month; + } else { + monthLabels.push(null); + } + } + return { columns, monthLabels }; +} + +export function ActivityHeatmap(props: { heatmap: ProfileHeatmap }) { + const { heatmap } = props; + const { columns, monthLabels } = buildColumns(heatmap.cells); + + return ( +
+
+ {monthLabels.map((label, i) => ( +
+ {label ?? " "} +
+ ))} +
+
+ {columns.map((col, i) => ( +
+ {col.map((cell, j) => + cell ? ( +
+ ) : ( +
+ ), + )} +
+ ))} +
+
+ Less + {([0, 1, 2, 3, 4] as ProfileHeatmapIntensity[]).map((level) => ( +
+ ))} + More +
+
+ ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx b/src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx new file mode 100644 index 00000000..a364eeef --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/ActivityInsights.tsx @@ -0,0 +1,40 @@ +import type { ProfileCoreStats } from "@/shared/contracts"; + +function Row(props: { label: string; value: string }) { + return ( +
+ {props.label} + {props.value} +
+ ); +} + +export function ActivityInsights(props: { core: ProfileCoreStats }) { + const { insights, totals } = props.core; + + const reasoning = insights.topReasoning + ? `${insights.topReasoning.label} - ${insights.topReasoning.percent}%` + : "-"; + const provider = insights.topProvider + ? `${insights.topProvider.label} - ${insights.topProvider.percent}%` + : "-"; + const activeHour = insights.mostActiveHour ? insights.mostActiveHour.label : "-"; + + return ( +
+

Activity insights

+
+ + + + + + + + + + +
+
+ ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/ActivitySection.tsx b/src/renderer/views/ProfileOverlay/parts/ActivitySection.tsx new file mode 100644 index 00000000..512553f6 --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/ActivitySection.tsx @@ -0,0 +1,40 @@ +import type { ProfileHeatmap } from "@/shared/contracts"; +import { LightballTabs, type LightballTab } from "@/renderer/components/common"; +import { ActivityHeatmap } from "./ActivityHeatmap"; + +export type ActivityMetric = "prompts" | "tokens"; + +export function ActivitySection(props: { + promptHeatmap: ProfileHeatmap; + tokenHeatmap: ProfileHeatmap | null; + tokensAvailable: boolean; + metric: ActivityMetric; + onMetricChange: (metric: ActivityMetric) => void; +}) { + const { promptHeatmap, tokenHeatmap, tokensAvailable, metric, onMetricChange } = props; + const showTokens = metric === "tokens" && tokensAvailable && tokenHeatmap; + const heatmap = showTokens ? tokenHeatmap : promptHeatmap; + + const tabs: ReadonlyArray> = [ + { id: "prompts", label: "Prompts" }, + { id: "tokens", label: "Tokens", disabled: !tokensAvailable }, + ]; + + return ( +
+
+

Activity

+ +
+ +
+ ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/AiActions.tsx b/src/renderer/views/ProfileOverlay/parts/AiActions.tsx new file mode 100644 index 00000000..0e3e91f4 --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/AiActions.tsx @@ -0,0 +1,50 @@ +import { GitCommitHorizontal, GitMerge, GitPullRequest } from "lucide-react"; +import type { AiActionType, ProfileAiAction } from "@/shared/contracts"; + +const ICONS: Record = { + commit: GitCommitHorizontal, + pr: GitPullRequest, + conflict: GitMerge, +}; + +/** AI-performed git actions (commits / PRs / conflict resolutions). */ +export function AiActions(props: { actions: ProfileAiAction[] }) { + const { actions } = props; + + return ( +
+

AI git actions

+ {actions.length === 0 ? ( +

+ No AI commits, PRs, or conflict resolutions tracked yet. +

+ ) : ( +
+ {actions.map((action) => { + const Icon = ICONS[action.type]; + const via = action.topProvider + ? `${action.topProvider}${action.topModel ? ` - ${action.topModel}` : ""}` + : null; + return ( +
+ + + {action.label} + + + {via ? {via} : null} + + {action.count.toLocaleString()} + + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/BreakdownBars.tsx b/src/renderer/views/ProfileOverlay/parts/BreakdownBars.tsx new file mode 100644 index 00000000..12145c97 --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/BreakdownBars.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from "react"; +import type { ProfileBreakdownEntry } from "@/shared/contracts"; + +function SkeletonRow() { + return ( +
+
+
+
+
+
+
+ ); +} + +/** A titled list of percent-weighted bars (providers, models, ...). */ +export function BreakdownBars(props: { + title: string; + caption?: string; + entries: ProfileBreakdownEntry[]; + limit?: number; + loading?: boolean; + loadingRows?: number; + emptyText?: string; + footer?: ReactNode; +}) { + const { + title, + caption, + entries, + limit = 6, + loading = false, + loadingRows = 4, + emptyText, + footer, + } = props; + const rows = entries.slice(0, limit); + + return ( +
+
+

{title}

+ {caption ? {caption} : null} +
+ {loading ? ( +
+ {Array.from({ length: loadingRows }).map((_, i) => ( + + ))} +
+ ) : rows.length === 0 ? ( +

{emptyText ?? "No data yet."}

+ ) : ( +
+ {rows.map((entry) => ( +
+
+ {entry.label} + {entry.percent}% +
+
+
+
+
+ ))} +
+ )} + {footer} +
+ ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/DevicePicker.tsx b/src/renderer/views/ProfileOverlay/parts/DevicePicker.tsx new file mode 100644 index 00000000..2fe4dbc1 --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/DevicePicker.tsx @@ -0,0 +1,60 @@ +import { useState, type ReactNode } from "react"; +import { Popover } from "@heroui/react"; +import { Check, ChevronDown } from "lucide-react"; +import { Button } from "@/renderer/components/common"; + +export interface DeviceOption { + id: string; + label: string; + icon: ReactNode; + hint?: string; +} + +/** + * Profile-local device selector. A dedicated picker (rather than the shared + * OptionMenu) so the option/trigger text renders in full `text-foreground` + * white, matching the monochrome profile styling. + */ +export function DevicePicker(props: { + value: string; + options: DeviceOption[]; + onChange: (id: string) => void; +}) { + const { value, options, onChange } = props; + const [open, setOpen] = useState(false); + const selected = options.find((o) => o.id === value) ?? options[0]; + + return ( + + + + + {open ? ( + + + {options.map((opt) => ( + + ))} + + + ) : null} + + ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx b/src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx new file mode 100644 index 00000000..85e94243 --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import { Check } from "lucide-react"; +import { Label, Modal } from "@heroui/react"; +import { Button, Input } from "@/renderer/components/common"; +import type { ProfileIdentity } from "@/shared/contracts"; +import { initialsFor } from "../format"; + +const AVATAR_PALETTE = [ + "oklch(0.62 0.11 245)", + "oklch(0.6 0.14 295)", + "oklch(0.58 0.15 25)", + "oklch(0.6 0.13 150)", + "oklch(0.66 0.13 78)", + "oklch(0.6 0.12 200)", +]; + +export function EditProfileDialog(props: { + open: boolean; + identity: ProfileIdentity; + onClose: () => void; + onSave: (identity: ProfileIdentity) => Promise; +}) { + const { open, identity, onClose, onSave } = props; + const [name, setName] = useState(identity.name); + const [handle, setHandle] = useState(identity.handle); + const [avatarColor, setAvatarColor] = useState(identity.avatarColor); + const [saving, setSaving] = useState(false); + + // Reset the form to the latest identity each time the dialog opens. + useEffect(() => { + if (open) { + setName(identity.name); + setHandle(identity.handle); + setAvatarColor( + AVATAR_PALETTE.includes(identity.avatarColor) ? identity.avatarColor : AVATAR_PALETTE[0]!, + ); + } + }, [open, identity]); + + async function handleSave() { + setSaving(true); + try { + await onSave({ + ...identity, + name: name.trim() || identity.name, + handle, + avatarColor, + }); + onClose(); + } finally { + setSaving(false); + } + } + + return ( + !next && onClose()}> + + +
+
+
+ {initialsFor(name || identity.name)} +
+

Edit profile

+
+ +
+ + setName(e.target.value)} + placeholder="Your name" + /> +
+ +
+ + + setHandle(e.target.value.replace(/[^a-zA-Z0-9]/g, "").toLowerCase()) + } + placeholder="handle" + /> +
+ +
+ +
+ {AVATAR_PALETTE.map((color) => ( + + ))} +
+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/ModelUsage.tsx b/src/renderer/views/ProfileOverlay/parts/ModelUsage.tsx new file mode 100644 index 00000000..819d1c6e --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/ModelUsage.tsx @@ -0,0 +1,36 @@ +import type { ProfileBreakdownEntry, ProfileTokenStats } from "@/shared/contracts"; +import { BreakdownBars } from "./BreakdownBars"; + +export function ModelUsage(props: { + tokens: ProfileTokenStats | null; + coreModels: ProfileBreakdownEntry[]; + tokensLoading: boolean; +}) { + const { tokens, coreModels, tokensLoading } = 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; + const models = byTokens ? tokens!.models : coreModels; + + if (!pending && models.length === 0) return null; + + const footer = + byTokens && tokens!.providers.length > 0 ? ( +

+ Tokens from {tokens!.providers.map((p) => p.label).join(", ")} +

+ ) : undefined; + + return ( + + ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/PluginUsage.tsx b/src/renderer/views/ProfileOverlay/parts/PluginUsage.tsx new file mode 100644 index 00000000..f9b7c62d --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/PluginUsage.tsx @@ -0,0 +1,49 @@ +import { Bot, Plug, Sparkles, Wrench } from "lucide-react"; +import type { ProfileSkillUsage } from "@/shared/contracts"; +import { formatRuns } from "../format"; + +function iconFor(kind: ProfileSkillUsage["kind"]) { + if (kind === "subagent") return Bot; + if (kind === "mcp") return Plug; + if (kind === "tool") return Wrench; + return Sparkles; +} + +export function PluginUsage(props: { + items: ProfileSkillUsage[]; + title?: string; + emptyText?: string; +}) { + const { items, title = "Most used plugins", emptyText } = props; + + return ( +
+

{title}

+ {items.length === 0 ? ( +

+ {emptyText ?? "Nothing tracked yet. It'll appear here as you use it."} +

+ ) : ( +
+ {items.map((item) => { + const Icon = iconFor(item.kind); + return ( +
+ + + {item.displayName} + + + {formatRuns(item.runCount)} + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/ProfileHeader.tsx b/src/renderer/views/ProfileOverlay/parts/ProfileHeader.tsx new file mode 100644 index 00000000..683e7aa9 --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/ProfileHeader.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react"; +import { Globe, Laptop, Monitor, MonitorSmartphone } from "lucide-react"; +import type { ProfileDevice, ProfileIdentity } from "@/shared/contracts"; +import { initialsFor } from "../format"; +import type { ProfileSelection } from "../useProfileData"; +import { DevicePicker } from "./DevicePicker"; + +const ALL_DEVICES = "all"; + +function platformIcon(platform: string): ReactNode { + if (platform === "darwin") return ; + if (platform === "win32" || platform === "linux") return ; + return ; +} + +function lastActiveLabel(device: ProfileDevice): string { + if (device.isCurrent) return "This device"; + if (!device.lastActiveAt) return ""; + const diff = Date.now() - device.lastActiveAt; + const day = 86_400_000; + if (diff < day) return "Active today"; + const days = Math.floor(diff / day); + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; +} + +export function ProfileHeader(props: { + identity: ProfileIdentity; + devices: ProfileDevice[]; + currentDeviceId: string | null; + selection: ProfileSelection; + onSelect: (selection: ProfileSelection) => void; + /** Rendered on the same row as the device picker (Share / Edit). */ + actions?: ReactNode; +}) { + const { identity, devices, currentDeviceId, selection, onSelect, actions } = props; + const plan = identity.plan ?? "Local"; + + const value = + selection.scope === "all" + ? ALL_DEVICES + : (selection.deviceId ?? currentDeviceId ?? ALL_DEVICES); + + const options = [ + { + id: ALL_DEVICES, + label: "All devices", + icon: , + hint: devices.length > 1 ? `${devices.length} devices` : "Syncs with Cloud", + }, + ...devices.map((d) => ({ + id: d.id, + label: d.label, + icon: platformIcon(d.platform), + hint: lastActiveLabel(d), + })), + ]; + + return ( +
+
+ {initialsFor(identity.name)} +
+
+

{identity.name}

+

+ @{identity.handle} + - + {plan} +

+
+ + {/* Device selector + actions on one row. Device picker chooses a single + device or the merged "All devices" (Cloud) view; today only the + current device has local data. */} +
+ + onSelect(id === ALL_DEVICES ? { scope: "all" } : { scope: "device", deviceId: id }) + } + /> + {actions} +
+
+ ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/ShareCard.tsx b/src/renderer/views/ProfileOverlay/parts/ShareCard.tsx new file mode 100644 index 00000000..6f8f5a91 --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/ShareCard.tsx @@ -0,0 +1,80 @@ +import { forwardRef } from "react"; +import type { ProfileCoreStats, ProfileTokenStats } from "@/shared/contracts"; +import { ProviderIcon } from "@/renderer/components/providers/ProviderIcon"; +import { formatCompact, formatDays, initialsFor } from "../format"; +import { ActivityHeatmap } from "./ActivityHeatmap"; +import type { ActivityMetric } from "./ActivitySection"; + +function Stat(props: { value: string; label: string }) { + return ( +
+
{props.value}
+
{props.label}
+
+ ); +} + +/** + * A fixed-width, opaque, screenshot-ready summary card. Captured to a PNG by the + * main process (webContents.capturePage) so it can be pasted into social posts. + */ +export const ShareCard = forwardRef< + HTMLDivElement, + { + core: ProfileCoreStats; + tokens: ProfileTokenStats | null; + metric: ActivityMetric; + } +>(function ShareCard({ core, tokens, metric }, ref) { + const { identity, totals, insights, promptHeatmap } = core; + const provider = insights.topProvider; + const lifetime = tokens?.available ? formatCompact(tokens.lifetimeTokens) : "-"; + const peak = tokens?.available ? formatCompact(tokens.peakDayTokens) : "-"; + // Mirror the metric selected on the profile page. + const heatmap = metric === "tokens" && tokens?.available ? tokens.tokenHeatmap : promptHeatmap; + + return ( +
+
+
+ {initialsFor(identity.name)} +
+
+ + {identity.name} + + @{identity.handle} +
+ {provider ? ( +
+ + {provider.label} +
+ ) : null} +
+ + + +
+ + + + +
+ +
+ Lightcode +
+
+ ); +}); diff --git a/src/renderer/views/ProfileOverlay/parts/ShareDialog.tsx b/src/renderer/views/ProfileOverlay/parts/ShareDialog.tsx new file mode 100644 index 00000000..179f31bc --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/ShareDialog.tsx @@ -0,0 +1,59 @@ +import { useRef, useState } from "react"; +import { Modal } from "@heroui/react"; +import { Check, Copy } from "lucide-react"; +import { Button } from "@/renderer/components/common"; +import { readBridge } from "@/renderer/bridge"; +import type { ProfileCoreStats, ProfileTokenStats } from "@/shared/contracts"; +import { ShareCard } from "./ShareCard"; +import type { ActivityMetric } from "./ActivitySection"; + +export function ShareDialog(props: { + open: boolean; + core: ProfileCoreStats; + tokens: ProfileTokenStats | null; + metric: ActivityMetric; + onClose: () => void; +}) { + const { open, core, tokens, metric, onClose } = props; + const cardRef = useRef(null); + const [copied, setCopied] = useState(false); + + async function copyImage() { + const el = cardRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return; + try { + await readBridge().copyShareImage({ x: r.left, y: r.top, width: r.width, height: r.height }); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + } + } + + return ( + !next && onClose()}> + + +
+

+ Share your activity +

+ +
+ +
+ +
+ +
+
+
+
+
+ ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/StatStrip.tsx b/src/renderer/views/ProfileOverlay/parts/StatStrip.tsx new file mode 100644 index 00000000..ce7b387f --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/StatStrip.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from "react"; +import type { ProfileCoreStats, ProfileTokenStats } from "@/shared/contracts"; +import { formatCompact, formatDays, formatDayLabel, formatDuration } from "../format"; + +function Skeleton() { + return
; +} + +/** + * Every tile reserves the same fixed heights for the value row (h-7) and the + * sub row (h-3.5) so the strip never reflows when async token tiles resolve or + * the peak-day sub-label appears. Numerals use tabular-nums for stable width. + */ +function Tile(props: { value: ReactNode; label: string; sub?: string }) { + return ( +
+
+ {props.value} +
+
{props.label}
+
{props.sub ?? ""}
+
+ ); +} + +export function StatStrip(props: { + core: ProfileCoreStats; + tokens: ProfileTokenStats | null; + tokensLoading: boolean; +}) { + const { core, tokens, tokensLoading } = props; + const totals = core.totals; + const pending = tokensLoading && !tokens; + + const lifetime = pending ? ( + + ) : tokens?.available ? ( + formatCompact(tokens.lifetimeTokens) + ) : ( + "-" + ); + const peak = pending ? ( + + ) : tokens?.available ? ( + formatCompact(tokens.peakDayTokens) + ) : ( + "-" + ); + + return ( +
+ + + + + +
+ ); +} diff --git a/src/renderer/views/ProfileOverlay/useProfileData.ts b/src/renderer/views/ProfileOverlay/useProfileData.ts new file mode 100644 index 00000000..a8c53db2 --- /dev/null +++ b/src/renderer/views/ProfileOverlay/useProfileData.ts @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import type { + ProfileCoreStats, + ProfileDevice, + ProfileIdentity, + ProfileStatScope, + ProfileTokenStats, +} from "@/shared/contracts"; +import { readBridge } from "@/renderer/bridge"; + +export interface ProfileSelection { + scope: ProfileStatScope; + /** Selected device id when scope === "device"; undefined = current device. */ + deviceId?: string; +} + +export interface ProfileData { + devices: ProfileDevice[]; + currentDeviceId: string | null; + selection: ProfileSelection; + setSelection: (selection: ProfileSelection) => void; + core: ProfileCoreStats | null; + coreLoading: boolean; + tokens: ProfileTokenStats | null; + tokensLoading: boolean; + error: string | null; + /** Optimistically apply an identity edit and persist it. */ + saveIdentity: (identity: ProfileIdentity) => Promise; +} + +/** + * Fetches the profile in two tiers so the page paints instantly: core stats + * first, token rollups in the background. The device list + `selection` drive + * the per-device view - today only the current device resolves to local data; + * Cloud will populate the rest. + */ +export function useProfileData(): ProfileData { + const [devices, setDevices] = useState([]); + const [currentDeviceId, setCurrentDeviceId] = useState(null); + const [selection, setSelection] = useState({ scope: "device" }); + const [core, setCore] = useState(null); + const [coreLoading, setCoreLoading] = useState(true); + const [tokens, setTokens] = useState(null); + const [tokensLoading, setTokensLoading] = useState(true); + const [error, setError] = useState(null); + + // Device list is independent of the selected scope - fetch once. + useEffect(() => { + let active = true; + void readBridge() + .getProfileDevices() + .then((result) => { + if (!active) return; + setDevices(result.devices); + setCurrentDeviceId(result.currentDeviceId); + }) + .catch(() => { + if (active) setDevices([]); + }); + return () => { + active = false; + }; + }, []); + + const { scope, deviceId } = selection; + useEffect(() => { + let active = true; + const utcOffsetMinutes = -new Date().getTimezoneOffset(); + const req = { utcOffsetMinutes, scope, ...(deviceId ? { deviceId } : {}) }; + setCoreLoading(true); + setTokensLoading(true); + setError(null); + + void readBridge() + .getProfileCoreStats(req) + .then((result) => { + if (active) setCore(result); + }) + .catch((err: unknown) => { + if (active) setError(err instanceof Error ? err.message : "Failed to load profile stats."); + }) + .finally(() => { + if (active) setCoreLoading(false); + }); + + void readBridge() + .getProfileTokenStats(req) + .then((result) => { + if (active) setTokens(result); + }) + .catch(() => { + // Token rollup is best-effort; the core stats still render. + if (active) setTokens(null); + }) + .finally(() => { + if (active) setTokensLoading(false); + }); + + return () => { + active = false; + }; + }, [scope, deviceId]); + + async function saveIdentity(identity: ProfileIdentity): Promise { + const response = await readBridge().setProfileIdentity(identity); + setCore((prev) => (prev ? { ...prev, identity: response.identity } : prev)); + } + + return { + devices, + currentDeviceId, + selection, + setSelection, + core, + coreLoading, + tokens, + tokensLoading, + error, + saveIdentity, + }; +} diff --git a/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx b/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx index 57d37678..82f15968 100644 --- a/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx +++ b/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx @@ -7,6 +7,7 @@ import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; import { buildWslProjectDistrosKey } from "@/renderer/state/projectKeys"; import { PageLayout } from "@/renderer/components/layout/PageLayout"; import { getSettingsInstalledAgents } from "@/shared/agentStatus"; +import { ProfileSettings } from "./parts/ProfileSettings"; import { AppearanceSettings } from "./parts/AppearanceSettings"; import { BrowserSettings } from "./parts/BrowserSettings"; import { UsageSettings } from "./parts/UsageSettings"; @@ -28,6 +29,7 @@ import { AgentSettingsEmpty, SingleAgentSettings } from "./parts/SingleAgentSett import type { SettingsSection } from "./parts/types"; const SECTION_VIEWS: Partial ReactNode>> = { + profile: () => , general: () => , audio: () => , appearance: () => , diff --git a/src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx b/src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx new file mode 100644 index 00000000..19608878 --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from "react"; +import { Pencil, Share2 } from "lucide-react"; +import { Button, PixelLoader } from "@/renderer/components/common"; +import { useProfileData } from "@/renderer/views/ProfileOverlay/useProfileData"; +import { ProfileHeader } from "@/renderer/views/ProfileOverlay/parts/ProfileHeader"; +import { StatStrip } from "@/renderer/views/ProfileOverlay/parts/StatStrip"; +import { + ActivitySection, + type ActivityMetric, +} from "@/renderer/views/ProfileOverlay/parts/ActivitySection"; +import { ActivityInsights } from "@/renderer/views/ProfileOverlay/parts/ActivityInsights"; +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 { AiActions } from "@/renderer/views/ProfileOverlay/parts/AiActions"; +import { EditProfileDialog } from "@/renderer/views/ProfileOverlay/parts/EditProfileDialog"; +import { ShareDialog } from "@/renderer/views/ProfileOverlay/parts/ShareDialog"; + +/** 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 { 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); + }; + + if (coreLoading && !core) { + return ( +
+ +
+ ); + } + if (!core) { + return ( +
+ {data.error ?? "Couldn't load your profile stats."} +
+ ); + } + + // 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; + + // 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 headerActions = ( + <> + + + + ); + + return ( +
+
+ + + +
+ + +
+
+ + +
+ {hasMultipleAccounts ? ( + + ) : null} +
+ + +
+
+ +
+
+ + setEditOpen(false)} + onSave={data.saveIdentity} + /> + setShareOpen(false)} + /> +
+ ); +} diff --git a/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx b/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx index bd53868f..06ac8aa0 100644 --- a/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx +++ b/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx @@ -20,6 +20,7 @@ import { Settings2, Sparkles, TerminalSquare, + UserRound, } from "lucide-react"; import { isClaudeProfileKind, type AgentStatus } from "@/shared/contracts"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; @@ -101,6 +102,13 @@ export function SettingsSidebar(props: { {isCollapsed && (
+ } + label="Profile" + isActive={activeSection === "profile"} + onPress={() => onSectionChange("profile")} + /> } @@ -310,6 +318,12 @@ export function SettingsSidebar(props: { >
+ } + label="Profile" + isActive={activeSection === "profile"} + onPress={() => onSectionChange("profile")} + /> } label="General" diff --git a/src/renderer/views/SettingsOverlay/parts/types.ts b/src/renderer/views/SettingsOverlay/parts/types.ts index 3454b9f5..fcd2b031 100644 --- a/src/renderer/views/SettingsOverlay/parts/types.ts +++ b/src/renderer/views/SettingsOverlay/parts/types.ts @@ -1,4 +1,5 @@ export type SettingsSection = + | "profile" | "general" | "audio" | "appearance" diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 2e4ac8e6..67fadb9e 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -13,3 +13,4 @@ export * from "./contracts/agentInstance"; export * from "./contracts/workflowTranscript"; export * from "./contracts/usage"; export * from "./contracts/notes"; +export * from "./contracts/profile"; diff --git a/src/shared/contracts/profile.ts b/src/shared/contracts/profile.ts new file mode 100644 index 00000000..12530bbb --- /dev/null +++ b/src/shared/contracts/profile.ts @@ -0,0 +1,259 @@ +import { z } from "zod"; + +/** + * Profile & usage-statistics contracts. + * + * These power the Profile page (a Codex/Synara-style identity + usage dashboard) + * and are deliberately shaped to survive the jump from LOCAL-only aggregation to + * the future Lightcode Cloud, where the same stats are synced and merged across + * a user's devices. + * + * Cloud-readiness rules baked into the model: + * - Every computed stats blob is attributed to a {@link ProfileDevice}. Locally + * there is exactly one device; Cloud will persist one contribution per device + * and merge them server-side into a `scope: "all"` view, while still allowing + * the user to inspect any single device (`scope: "device"`). + * - `scope` is carried on every result so the same renderer code renders the + * "this device" and "all devices" views unchanged. + * - All shapes are plain JSON (no `Date`), so a blob can be uploaded verbatim. + * - Heatmap intensity is pre-bucketed (0-4) by the producer so the renderer + * stays dumb and a merged Cloud blob renders identically to a local one. + * - `timezoneOffsetMinutes` is echoed back so the consumer knows which local + * calendar the day/hour buckets were computed against. + */ + +// -- Scope & device --------------------------------------------------- + +export const profileStatScopeSchema = z.enum(["device", "all"]); +/** "device" = this install only; "all" = merged across the user's devices (Cloud). */ +export type ProfileStatScope = z.infer; + +export interface ProfileDevice { + /** Stable per-install id (generated once, persisted in app_state). */ + id: string; + /** Human label, e.g. the machine hostname. */ + label: string; + /** `process.platform` - "darwin" | "win32" | "linux". */ + platform: string; + /** True for the machine this app instance is running on. */ + isCurrent?: boolean; + /** Epoch ms this device was last seen reporting stats. */ + lastActiveAt?: number; +} + +export interface ProfileDevicesResponse { + devices: ProfileDevice[]; + currentDeviceId: string; +} + +// -- Editable identity (local override; Cloud account later) ----------- + +export const profileIdentitySchema = z.object({ + name: z.string().max(80), + /** Handle without the leading "@". */ + handle: z.string().max(40), + /** Avatar background color token (any CSS color; defaults to an accent). */ + avatarColor: z.string().max(64), + /** Plan label - "Local" today; the Cloud subscription tier later. */ + plan: z.string().max(40).optional(), +}); +export type ProfileIdentity = z.infer; + +// -- Aggregate building blocks ---------------------------------------- + +export interface ProfileTotals { + totalThreads: number; + /** Completed turns - one per user prompt that ran to completion. */ + totalPrompts: number; + /** Messages the user sent via the composer (native user_message items). */ + messagesSent: number; + /** Goals set in structured sessions. */ + goalsSet: number; + /** Longest single turn (first input -> idle), in ms. */ + longestTaskMs: number; + currentStreakDays: number; + longestStreakDays: number; + /** Distinct local days with activity within the heatmap window. */ + activeDays: number; +} + +export type ProfileHeatmapIntensity = 0 | 1 | 2 | 3 | 4; + +export interface ProfileHeatmapCell { + /** Local calendar day, `YYYY-MM-DD`. */ + day: string; + count: number; + /** Pre-bucketed 0-4 relative to the window max. */ + intensity: ProfileHeatmapIntensity; +} + +export interface ProfileHeatmap { + metric: "prompts" | "tokens"; + windowDays: number; + /** One cell per day across the window, oldest -> newest. */ + cells: ProfileHeatmapCell[]; + /** Max single-day count in the window (for legend / tooltips). */ + max: number; +} + +/** A ranked slice (provider, model, reasoning effort, ...). */ +export interface ProfileBreakdownEntry { + key: string; + label: string; + /** Turns (core stats) or tokens (token stats) attributed to this slice. */ + count: number; + /** 0-100, one decimal place. */ + percent: number; +} + +export interface ProfileActiveHour { + /** 0-23 local hour. */ + hour: number; + label: string; + count: number; +} + +export interface ProfileInsights { + topProvider?: ProfileBreakdownEntry; + topModel?: ProfileBreakdownEntry; + topReasoning?: ProfileBreakdownEntry; + /** Share of turns run with fast mode on, 0-100. */ + fastModePercent: number; + mostActiveHour?: ProfileActiveHour; + /** Distinct skills/subagents invoked. */ + skillsExplored: number; + /** Total skill/subagent invocations. */ + totalSkillsUsed: number; +} + +export interface ProfileSkillUsage { + name: string; + /** `$skill` for skills, `@agent` for subagents, raw name for plain tools/MCP. */ + displayName: string; + kind: "skill" | "subagent" | "tool" | "mcp"; + runCount: number; +} + +export const aiActionTypeSchema = z.enum(["commit", "pr", "conflict"]); +export type AiActionType = z.infer; + +/** AI-performed git actions (commits, PRs, conflict resolutions). */ +export interface ProfileAiAction { + type: AiActionType; + label: string; + count: number; + /** Provider/model that performed the most of this action (display labels). */ + topProvider?: string; + topModel?: string; +} + +/** A single durable usage event captured at the canonical-event layer. */ +export const usageEventInputSchema = z.object({ + ts: z.number(), + kind: z.string().min(1), + provider: z.string().nullable().optional(), + model: z.string().nullable().optional(), + mode: z.string().nullable().optional(), + fast: z.boolean().optional(), + effort: z.string().nullable().optional(), + name: z.string().nullable().optional(), + value: z.number().optional(), +}); +export type UsageEventInputPayload = z.infer; + +export const appendUsageEventsSchema = z.object({ + events: z.array(usageEventInputSchema), +}); +export type AppendUsageEventsPayload = z.infer; + +// -- Core stats (fast: from the local SQLite store) ------------------- + +export interface ProfileCoreStats { + scope: ProfileStatScope; + device: ProfileDevice; + /** Epoch ms when this blob was produced. */ + generatedAt: number; + timezoneOffsetMinutes: number; + identity: ProfileIdentity; + totals: ProfileTotals; + promptHeatmap: ProfileHeatmap; + insights: ProfileInsights; + /** Turn-weighted provider mix, folded to the base provider (global). */ + providers: ProfileBreakdownEntry[]; + /** Turn-weighted per-account mix (each profile of a provider separately). */ + accounts: ProfileBreakdownEntry[]; + /** Turn-weighted model mix (label includes provider). */ + models: ProfileBreakdownEntry[]; + /** Threads started by presentation mode (chat vs CLI). */ + modes: ProfileBreakdownEntry[]; + /** Top skills/subagents by run count. */ + skills: ProfileSkillUsage[]; + /** Top MCP servers by tool-call count. */ + mcps: ProfileSkillUsage[]; + /** AI-performed git actions (commits / PRs / conflict resolutions). */ + aiActions: ProfileAiAction[]; +} + +// -- Token stats (durable local usage log) ---------------------------- + +export interface ProfileTokenProvider { + provider: string; + label: string; + tokens: number; + /** 0-100 share of total recorded tokens. */ + percent: number; + /** Estimated USD at public list rates (subscription users are NOT billed this). */ + estimatedCostUsd?: number; +} + +export interface ProfileTokenStats { + /** False when no durable token events have been recorded on this device yet. */ + available: boolean; + scope: ProfileStatScope; + device: ProfileDevice; + generatedAt: number; + timezoneOffsetMinutes: number; + windowDays: number; + /** Total retained tokens (bounded by the local usage-event retention window). */ + lifetimeTokens: number; + peakDayTokens: number; + peakDay?: string; + /** Per-provider token totals, folded to the base provider (global). */ + providers: ProfileTokenProvider[]; + /** Per-account token totals (each profile of a provider separately). */ + accounts: ProfileTokenProvider[]; + /** Token-weighted model mix. */ + models: ProfileBreakdownEntry[]; + tokenHeatmap: ProfileHeatmap; +} + +// -- IPC payloads ----------------------------------------------------- + +export const profileStatsRequestSchema = z.object({ + /** `-new Date().getTimezoneOffset()` from the renderer, for local bucketing. */ + utcOffsetMinutes: z.number(), + scope: profileStatScopeSchema.optional(), + /** + * When `scope: "device"`, which device to report. Defaults to the current + * device. A non-current id has no local data (Cloud will serve it) and yields + * an empty-but-valid blob today. + */ + deviceId: z.string().optional(), +}); +export type ProfileStatsRequest = z.infer; + +export interface ProfileIdentityResponse { + identity: ProfileIdentity; + device: ProfileDevice; +} + +// -- Share-card image capture ----------------------------------------- + +/** Viewport rect (CSS px) of the share card element to screenshot. */ +export const shareImageRectSchema = z.object({ + x: z.number(), + y: z.number(), + width: z.number().positive(), + height: z.number().positive(), +}); +export type ShareImageRect = z.infer; diff --git a/src/shared/ipc/procedureMap.ts b/src/shared/ipc/procedureMap.ts index 11521ec1..0bf9bd0c 100644 --- a/src/shared/ipc/procedureMap.ts +++ b/src/shared/ipc/procedureMap.ts @@ -4,6 +4,7 @@ import { dbProcedures } from "./procedures/db"; import { githubProcedures } from "./procedures/github"; import { gitProcedures } from "./procedures/git"; import { lspProcedures } from "./procedures/lsp"; +import { profileProcedures } from "./procedures/profile"; import { projectTreeProcedures } from "./procedures/projectTree"; import { settingsProcedures } from "./procedures/settings"; import { threadProcedures } from "./procedures/thread"; @@ -22,6 +23,7 @@ export const groupedIpcProcedures = { lsp: lspProcedures, browser: browserProcedures, usage: usageProcedures, + profile: profileProcedures, } as const; export const ipcProcedureMap = { @@ -36,6 +38,7 @@ export const ipcProcedureMap = { ...lspProcedures, ...browserProcedures, ...usageProcedures, + ...profileProcedures, } as const; export type IpcProcedureMap = typeof ipcProcedureMap; @@ -111,6 +114,13 @@ export const MAIN_LOCAL_PROCEDURE_NAMES = [ "clearUsageLogin", "resolveUsageLoginConfirmation", "getUsageLoginState", + "getProfileCoreStats", + "getProfileTokenStats", + "getProfileDevices", + "getProfileIdentity", + "setProfileIdentity", + "copyShareImage", + "appendUsageEvents", ] as const satisfies readonly IpcProcedureName[]; export type MainLocalProcedureName = (typeof MAIN_LOCAL_PROCEDURE_NAMES)[number]; diff --git a/src/shared/ipc/procedures/profile.ts b/src/shared/ipc/procedures/profile.ts new file mode 100644 index 00000000..41c1e1b8 --- /dev/null +++ b/src/shared/ipc/procedures/profile.ts @@ -0,0 +1,51 @@ +import { + appendUsageEventsSchema, + profileIdentitySchema, + profileStatsRequestSchema, + shareImageRectSchema, + type AppendUsageEventsPayload, + type ProfileCoreStats, + type ProfileDevicesResponse, + type ProfileIdentity, + type ProfileIdentityResponse, + type ProfileStatsRequest, + type ProfileTokenStats, + type ShareImageRect, +} from "../../contracts"; +import { defineNoArgProcedure, definePayloadProcedure } from "../core"; + +export const profileProcedures = { + getProfileCoreStats: definePayloadProcedure( + "getProfileCoreStats", + "main-local", + profileStatsRequestSchema, + ), + getProfileDevices: defineNoArgProcedure( + "getProfileDevices", + "main-local", + ), + getProfileTokenStats: definePayloadProcedure< + ProfileStatsRequest, + ProfileTokenStats, + "main-local" + >("getProfileTokenStats", "main-local", profileStatsRequestSchema), + getProfileIdentity: defineNoArgProcedure( + "getProfileIdentity", + "main-local", + ), + setProfileIdentity: definePayloadProcedure< + ProfileIdentity, + ProfileIdentityResponse, + "main-local" + >("setProfileIdentity", "main-local", profileIdentitySchema), + copyShareImage: definePayloadProcedure( + "copyShareImage", + "main-local", + shareImageRectSchema, + ), + appendUsageEvents: definePayloadProcedure( + "appendUsageEvents", + "main-local", + appendUsageEventsSchema, + ), +} as const;