11import { useEffect , useRef } from "react" ;
22
33import {
4+ type AccountUsage ,
5+ checkAccountUsage ,
46 checkLiveUsage ,
57 getPreference ,
68 getTokenUsageStats ,
79 isTauriAvailable ,
810 listProviderAccounts ,
11+ listSessions ,
912 type LiveUsageResult ,
1013 type ProviderAccount ,
1114 sendTrayNotification ,
15+ type SessionRow ,
1216 setTrayMenu ,
1317 setTrayText ,
1418} from "@/lib/tauri-ipc" ;
@@ -24,6 +28,11 @@ const SUPPORTED_PROVIDERS = new Set(["anthropic", "openai", "google"]);
2428// notification. Each (accountId × window × threshold) only fires once per app
2529// process — the ref below holds the latest threshold we've already notified.
2630const NOTIFY_THRESHOLDS = [ 75 , 90 , 100 ] ;
31+ const WEEKLY_USAGE_NOTIFY_THRESHOLDS = [ 90 , 100 ] ;
32+ const WEEKLY_PACE_AHEAD_NOTIFY_PCT = 50 ;
33+ const MIN_WEEKLY_PACE_NOTIFY_PCT = 10 ;
34+ const SESSION_USAGE_NOTIFY_THRESHOLD = 90 ;
35+ const ACTIVE_SESSION_WINDOW_MS = 15 * 60 * 1000 ;
2736
2837function formatTokens ( n : number ) : string {
2938 if ( n >= 1_000_000_000 ) return `${ ( n / 1_000_000_000 ) . toFixed ( 2 ) } B` ;
@@ -110,6 +119,60 @@ function buildTrayLine(
110119 return `${ label } ${ plan } — ${ live . status ?? "ok" } ` ;
111120}
112121
122+ function inferContextLimitTokens ( model : string | null , agentType : string ) : number | null {
123+ const normalized = ( model ?? "" ) . toLowerCase ( ) ;
124+
125+ if ( normalized . includes ( "claude" ) || agentType === "claude-code" ) {
126+ return 200_000 ;
127+ }
128+ if ( normalized . includes ( "gpt-4.1" ) || normalized . includes ( "gpt-4o" ) ) {
129+ return 128_000 ;
130+ }
131+ if (
132+ normalized === "o3" ||
133+ normalized . startsWith ( "o3-" ) ||
134+ normalized === "o4-mini"
135+ ) {
136+ return 200_000 ;
137+ }
138+ if ( normalized . includes ( "gemini-1.5" ) || normalized . includes ( "gemini-2" ) ) {
139+ return 1_000_000 ;
140+ }
141+
142+ return null ;
143+ }
144+
145+ function sessionUsagePct ( session : SessionRow ) : number | null {
146+ const limit = inferContextLimitTokens ( session . model_used , session . agent_type ) ;
147+ if ( ! limit ) return null ;
148+ const total = session . total_input_tokens + session . total_output_tokens ;
149+ if ( total <= 0 ) return null ;
150+ return ( total / limit ) * 100 ;
151+ }
152+
153+ function sessionLabel ( session : SessionRow ) : string {
154+ if ( session . cwd ) {
155+ const parts = session . cwd . split ( "/" ) . filter ( Boolean ) ;
156+ const name = parts . at ( - 1 ) ;
157+ if ( name ) return name ;
158+ }
159+ if ( session . slug ) return session . slug ;
160+ return `${ session . agent_type } session` ;
161+ }
162+
163+ function isRecentlyActiveSession ( session : SessionRow ) : boolean {
164+ if ( ! session . last_message ) return false ;
165+ const lastMessageMs = new Date ( session . last_message ) . getTime ( ) ;
166+ if ( ! Number . isFinite ( lastMessageMs ) ) return false ;
167+ return Date . now ( ) - lastMessageMs <= ACTIVE_SESSION_WINDOW_MS ;
168+ }
169+
170+ function weeklyPaceAheadPct ( usage : AccountUsage ) : number | null {
171+ if ( usage . week_pct == null || usage . expected_pct <= 0 ) return null ;
172+ if ( usage . week_pct < MIN_WEEKLY_PACE_NOTIFY_PCT ) return null ;
173+ return ( ( usage . week_pct - usage . expected_pct ) / usage . expected_pct ) * 100 ;
174+ }
175+
113176async function loadCadenceSecs ( ) : Promise < number > {
114177 if ( ! isTauriAvailable ( ) ) return DEFAULT_CADENCE_SECS ;
115178 try {
@@ -133,6 +196,16 @@ async function loadNotificationsEnabled(): Promise<boolean> {
133196 }
134197}
135198
199+ async function loadSessionNotificationsEnabled ( ) : Promise < boolean > {
200+ if ( ! isTauriAvailable ( ) ) return true ;
201+ try {
202+ const raw = await getPreference ( "notify_session_usage_thresholds" ) ;
203+ return raw !== "false" ;
204+ } catch {
205+ return true ;
206+ }
207+ }
208+
136209/**
137210 * Mounts a single global tray monitor at App level so the menu-bar icon stays
138211 * fresh regardless of which page the user is on. Polls accounts + live usage
@@ -142,6 +215,9 @@ export function useTrayMonitor(): void {
142215 // Persist last-notified threshold per accountId across re-renders so we
143216 // don't re-fire the same notification on every poll.
144217 const lastNotifiedRef = useRef < Record < string , number > > ( { } ) ;
218+ const weeklyUsageNotifiedRef = useRef < Record < string , number > > ( { } ) ;
219+ const weeklyPaceNotifiedRef = useRef < Record < string , number > > ( { } ) ;
220+ const sessionUsageNotifiedRef = useRef < Record < string , number > > ( { } ) ;
145221
146222 useEffect ( ( ) => {
147223 if ( ! isTauriAvailable ( ) ) return ;
@@ -170,6 +246,14 @@ export function useTrayMonitor(): void {
170246 if ( r . status === "fulfilled" ) liveMap [ supported [ i ] . id ] = r . value ;
171247 } ) ;
172248
249+ const usageResults = await Promise . allSettled (
250+ accounts . map ( ( a ) => checkAccountUsage ( a . id ) )
251+ ) ;
252+ const usageMap : Record < string , AccountUsage > = { } ;
253+ usageResults . forEach ( ( r , i ) => {
254+ if ( r . status === "fulfilled" ) usageMap [ accounts [ i ] . id ] = r . value ;
255+ } ) ;
256+
173257 // Today's tokens (separate query; cheap local SQLite call).
174258 const tokenUsage = await getTokenUsageStats ( ) . catch ( ( ) => null ) ;
175259
@@ -207,6 +291,68 @@ export function useTrayMonitor(): void {
207291 `Worst window utilization: ${ Math . round ( pct ) } %`
208292 ) . catch ( ( ) => { } ) ;
209293 }
294+
295+ for ( const a of accounts ) {
296+ const usage = usageMap [ a . id ] ;
297+ if ( ! usage ) continue ;
298+ const label = a . name || a . provider ;
299+
300+ if ( usage . week_pct != null ) {
301+ const crossed = WEEKLY_USAGE_NOTIFY_THRESHOLDS . filter (
302+ ( t ) => usage . week_pct != null && usage . week_pct >= t
303+ ) . pop ( ) ;
304+ const last = weeklyUsageNotifiedRef . current [ a . id ] ?? 0 ;
305+ if ( crossed && crossed > last ) {
306+ weeklyUsageNotifiedRef . current [ a . id ] = crossed ;
307+ const verb = crossed >= 100 ? "over weekly baseline" : `at ${ crossed } % weekly` ;
308+ await sendTrayNotification (
309+ `${ label } ${ verb } ` ,
310+ `Weekly usage is ${ Math . round ( usage . week_pct ) } % of baseline.`
311+ ) . catch ( ( ) => { } ) ;
312+ }
313+ }
314+
315+ const aheadPct = weeklyPaceAheadPct ( usage ) ;
316+ const paceLast = weeklyPaceNotifiedRef . current [ a . id ] ?? 0 ;
317+ if (
318+ aheadPct != null &&
319+ aheadPct >= WEEKLY_PACE_AHEAD_NOTIFY_PCT &&
320+ paceLast < WEEKLY_PACE_AHEAD_NOTIFY_PCT
321+ ) {
322+ weeklyPaceNotifiedRef . current [ a . id ] = WEEKLY_PACE_AHEAD_NOTIFY_PCT ;
323+ await sendTrayNotification (
324+ `${ label } is ahead of weekly pace` ,
325+ `Usage is ${ Math . round ( aheadPct ) } % ahead of schedule (${ Math . round (
326+ usage . week_pct ?? 0
327+ ) } % used vs ${ Math . round ( usage . expected_pct ) } % expected).`
328+ ) . catch ( ( ) => { } ) ;
329+ }
330+ }
331+ }
332+
333+ // ── Session context-usage notifications ────────────────────────
334+ const sessionNotifyEnabled = await loadSessionNotificationsEnabled ( ) ;
335+ if ( sessionNotifyEnabled ) {
336+ const sessions = await listSessions ( undefined , undefined , 25 ) . catch (
337+ ( ) => [ ] as SessionRow [ ]
338+ ) ;
339+ for ( const session of sessions ) {
340+ if ( ! isRecentlyActiveSession ( session ) ) continue ;
341+ const pct = sessionUsagePct ( session ) ;
342+ if ( pct == null || pct < SESSION_USAGE_NOTIFY_THRESHOLD ) continue ;
343+ const last = sessionUsageNotifiedRef . current [ session . id ] ?? 0 ;
344+ if ( last >= SESSION_USAGE_NOTIFY_THRESHOLD ) continue ;
345+ sessionUsageNotifiedRef . current [ session . id ] =
346+ SESSION_USAGE_NOTIFY_THRESHOLD ;
347+
348+ const total = session . total_input_tokens + session . total_output_tokens ;
349+ await sendTrayNotification (
350+ `${ sessionLabel ( session ) } is near its session limit` ,
351+ `${ Math . round ( pct ) } % used (${ formatTokens ( total ) } tokens) for ${
352+ session . model_used ?? session . agent_type
353+ } .`
354+ ) . catch ( ( ) => { } ) ;
355+ }
210356 }
211357 } catch {
212358 // Swallow — never let a failed poll break the loop.
0 commit comments