Skip to content

Commit 43249ba

Browse files
outsourc-eEric
andauthored
Hide usage meter by default + stop spurious context alerts (#442)
* feat: hide usage meter by default * fix: only alert on context threshold crossings --------- Co-authored-by: Eric <eric@EricsMacStudio.lan>
1 parent 336119e commit 43249ba

6 files changed

Lines changed: 212 additions & 53 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
resolveContextAlertThreshold,
4+
resolveUsageMeterSessionKey,
5+
shouldShowUsageMeterContextAlert,
6+
} from './usage-meter-session'
7+
8+
describe('usage meter session targeting', () => {
9+
it('uses the active chat session from the route pathname', () => {
10+
expect(resolveUsageMeterSessionKey('/chat/main')).toBe('main')
11+
expect(resolveUsageMeterSessionKey('/chat/new')).toBe('new')
12+
expect(resolveUsageMeterSessionKey('/chat/session-123')).toBe('session-123')
13+
})
14+
15+
it('decodes route params for chat sessions', () => {
16+
expect(resolveUsageMeterSessionKey('/chat/local%2Fmirror')).toBe('local/mirror')
17+
})
18+
19+
it('falls back to main outside chat routes', () => {
20+
expect(resolveUsageMeterSessionKey('/settings')).toBe('main')
21+
expect(resolveUsageMeterSessionKey('/dashboard')).toBe('main')
22+
})
23+
24+
it('only allows context alerts when the usage meter is visible on chat routes', () => {
25+
expect(
26+
shouldShowUsageMeterContextAlert({ pathname: '/chat/main', visible: true }),
27+
).toBe(true)
28+
expect(
29+
shouldShowUsageMeterContextAlert({ pathname: '/chat/main', visible: false }),
30+
).toBe(false)
31+
expect(
32+
shouldShowUsageMeterContextAlert({ pathname: '/settings', visible: true }),
33+
).toBe(false)
34+
})
35+
36+
it('does not alert on the first high reading without crossing a threshold', () => {
37+
expect(
38+
resolveContextAlertThreshold({
39+
previous: null,
40+
current: 85,
41+
thresholds: [50, 75, 90],
42+
sent: {},
43+
}),
44+
).toBeNull()
45+
})
46+
47+
it('alerts with the highest newly crossed threshold', () => {
48+
expect(
49+
resolveContextAlertThreshold({
50+
previous: 40,
51+
current: 85,
52+
thresholds: [50, 75, 90],
53+
sent: {},
54+
}),
55+
).toBe(75)
56+
})
57+
58+
it('skips thresholds already sent today', () => {
59+
expect(
60+
resolveContextAlertThreshold({
61+
previous: 70,
62+
current: 92,
63+
thresholds: [50, 75, 90],
64+
sent: { 75: true },
65+
}),
66+
).toBe(90)
67+
})
68+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export function resolveUsageMeterSessionKey(pathname: string): string {
2+
if (!pathname.startsWith('/chat/')) return 'main'
3+
const raw = pathname.slice('/chat/'.length).split('/')[0] || 'main'
4+
try {
5+
return decodeURIComponent(raw) || 'main'
6+
} catch {
7+
return raw || 'main'
8+
}
9+
}
10+
11+
export function shouldShowUsageMeterContextAlert({
12+
pathname,
13+
visible,
14+
}: {
15+
pathname: string
16+
visible: boolean
17+
}): boolean {
18+
return visible && pathname.startsWith('/chat/')
19+
}
20+
21+
export function resolveContextAlertThreshold({
22+
previous,
23+
current,
24+
thresholds,
25+
sent,
26+
}: {
27+
previous: number | null
28+
current: number
29+
thresholds: Array<number>
30+
sent: Record<number, boolean>
31+
}): number | null {
32+
if (!Number.isFinite(current)) return null
33+
if (previous === null || !Number.isFinite(previous)) return null
34+
if (current <= previous) return null
35+
36+
const crossed = thresholds.filter(
37+
(threshold) => previous < threshold && current >= threshold && !sent[threshold],
38+
)
39+
40+
if (crossed.length === 0) return null
41+
return crossed[crossed.length - 1] ?? null
42+
}

src/components/usage-meter/usage-meter.tsx

Lines changed: 84 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
'use client'
22

3+
import { useRouterState } from '@tanstack/react-router'
34
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
45
import { UsageDetailsModal } from './usage-details-modal'
56
import { ContextAlertModal } from './context-alert-modal'
7+
import {
8+
resolveContextAlertThreshold,
9+
resolveUsageMeterSessionKey,
10+
shouldShowUsageMeterContextAlert,
11+
} from './usage-meter-session'
612
import { DialogContent, DialogRoot } from '@/components/ui/dialog'
713
import {
814
MenuContent,
@@ -434,7 +440,16 @@ type AgentActivity = {
434440
totalAgentCost: number
435441
}
436442

437-
export function UsageMeter() {
443+
export function UsageMeter({ visible = true }: { visible?: boolean }) {
444+
const pathname = useRouterState({ select: (state) => state.location.pathname })
445+
const statusSessionKey = useMemo(
446+
() => resolveUsageMeterSessionKey(pathname),
447+
[pathname],
448+
)
449+
const contextAlertsEnabled = useMemo(
450+
() => shouldShowUsageMeterContextAlert({ pathname, visible }),
451+
[pathname, visible],
452+
)
438453
const [usage, setUsage] = useState<UsageSummary>(() =>
439454
parseSessionStatus(null),
440455
)
@@ -458,10 +473,14 @@ export function UsageMeter() {
458473
threshold: number
459474
}>({ open: false, threshold: 0 })
460475
const alertStateRef = useRef(getAlertState())
476+
const previousContextPercentRef = useRef<number | null>(null)
461477

462478
const refresh = useCallback(async () => {
463479
try {
464-
const res = await fetch('/api/session-status')
480+
const query = statusSessionKey
481+
? `?sessionKey=${encodeURIComponent(statusSessionKey)}`
482+
: ''
483+
const res = await fetch(`/api/session-status${query}`)
465484
if (!res.ok) {
466485
const data = await res.json().catch(() => null)
467486
throw new Error(
@@ -478,7 +497,7 @@ export function UsageMeter() {
478497
setError(errorMessage)
479498
toast('Failed to fetch usage data', { type: 'error' })
480499
}
481-
}, [])
500+
}, [statusSessionKey])
482501

483502
const refreshProviders = useCallback(async () => {
484503
try {
@@ -546,28 +565,43 @@ export function UsageMeter() {
546565
}, [refreshAgentActivity])
547566

548567
useEffect(() => {
568+
if (!contextAlertsEnabled && contextAlert.open) {
569+
setContextAlert({ open: false, threshold: 0 })
570+
}
571+
}, [contextAlert.open, contextAlertsEnabled])
572+
573+
useEffect(() => {
574+
if (!contextAlertsEnabled) {
575+
previousContextPercentRef.current = usage.contextPercent
576+
return
577+
}
549578
if (typeof window === 'undefined') return
550579
const current = usage.contextPercent
551580
if (!Number.isFinite(current)) return
581+
const previous = previousContextPercentRef.current
582+
previousContextPercentRef.current = current
552583
const state = alertStateRef.current
553584
if (state.date !== getTodayKey()) {
554585
state.date = getTodayKey()
555586
state.sent = {}
556587
}
557-
const eligible = THRESHOLDS.filter((threshold) => current >= threshold)
558-
if (eligible.length === 0) return
559-
for (const threshold of eligible) {
560-
if (state.sent[threshold]) continue
561-
state.sent[threshold] = true
562-
saveAlertState(state)
563-
// Show in-app modal instead of browser notification
564-
setContextAlert({ open: true, threshold })
565-
break // Only show one alert at a time
566-
}
567-
}, [usage.contextPercent])
588+
const threshold = resolveContextAlertThreshold({
589+
previous,
590+
current,
591+
thresholds: THRESHOLDS,
592+
sent: state.sent,
593+
})
594+
if (!threshold) return
595+
state.sent[threshold] = true
596+
saveAlertState(state)
597+
// Show in-app modal instead of browser notification
598+
setContextAlert({ open: true, threshold })
599+
}, [contextAlertsEnabled, usage.contextPercent])
568600

569601
useEffect(() => {
570602
function handleOpenUsageFromSearch() {
603+
void refresh()
604+
void refreshProviders()
571605
setOpen(true)
572606
}
573607

@@ -581,7 +615,7 @@ export function UsageMeter() {
581615
handleOpenUsageFromSearch,
582616
)
583617
}
584-
}, [])
618+
}, [refresh, refreshProviders])
585619

586620
// Find the preferred provider for the status bar display
587621
const [preferredProvider, setPreferredProvider] = useState<string | null>(
@@ -839,39 +873,41 @@ export function UsageMeter() {
839873

840874
return (
841875
<>
842-
<MenuRoot>
843-
<MenuTrigger
844-
className={cn(
845-
"absolute bottom-2 right-2",
846-
'ml-auto rounded-full border px-3 py-1 text-xs font-medium',
847-
'flex items-center gap-3 transition hover:bg-primary-100 cursor-pointer',
848-
alertTone,
849-
)}
850-
data-tour="usage-meter"
851-
>
852-
<span className="text-[9px] uppercase tracking-widest text-primary-500 opacity-75">
853-
{STATS_VIEW_LABELS[statsView].split(' ')[0]}
854-
</span>
855-
<span className="text-primary-300">|</span>
856-
{renderPillContent()}
857-
</MenuTrigger>
858-
<MenuContent align="end" className="min-w-[180px]">
859-
{(['session', 'provider', 'cost', 'agents'] as const).map((view) => (
860-
<MenuItem
861-
key={view}
862-
onClick={() => handleStatsViewChange(view)}
863-
className={cn(
864-
statsView === view && 'bg-amber-100 text-amber-800',
865-
)}
866-
>
867-
<span className="flex-1">{STATS_VIEW_LABELS[view]}</span>
868-
{statsView === view && <span className="text-amber-600"></span>}
869-
</MenuItem>
870-
))}
871-
<div className="my-1 h-px bg-primary-100" />
872-
<MenuItem onClick={() => setOpen(true)}>View Details…</MenuItem>
873-
</MenuContent>
874-
</MenuRoot>
876+
{visible ? (
877+
<MenuRoot>
878+
<MenuTrigger
879+
className={cn(
880+
"absolute bottom-2 right-2",
881+
'ml-auto rounded-full border px-3 py-1 text-xs font-medium',
882+
'flex items-center gap-3 transition hover:bg-primary-100 cursor-pointer',
883+
alertTone,
884+
)}
885+
data-tour="usage-meter"
886+
>
887+
<span className="text-[9px] uppercase tracking-widest text-primary-500 opacity-75">
888+
{STATS_VIEW_LABELS[statsView].split(' ')[0]}
889+
</span>
890+
<span className="text-primary-300">|</span>
891+
{renderPillContent()}
892+
</MenuTrigger>
893+
<MenuContent align="end" className="min-w-[180px]">
894+
{(['session', 'provider', 'cost', 'agents'] as const).map((view) => (
895+
<MenuItem
896+
key={view}
897+
onClick={() => handleStatsViewChange(view)}
898+
className={cn(
899+
statsView === view && 'bg-amber-100 text-amber-800',
900+
)}
901+
>
902+
<span className="flex-1">{STATS_VIEW_LABELS[view]}</span>
903+
{statsView === view && <span className="text-amber-600"></span>}
904+
</MenuItem>
905+
))}
906+
<div className="my-1 h-px bg-primary-100" />
907+
<MenuItem onClick={() => setOpen(true)}>View Details…</MenuItem>
908+
</MenuContent>
909+
</MenuRoot>
910+
) : null}
875911

876912
<DialogRoot open={open} onOpenChange={setOpen}>
877913
<DialogContent className="w-[min(720px,94vw)]">

src/hooks/use-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type StudioSettings = {
1111
claudeToken: string
1212
theme: SettingsThemeMode
1313
accentColor: AccentColor
14+
showUsageMeter: boolean
1415
editorFontSize: number
1516
editorWordWrap: boolean
1617
editorMinimap: boolean
@@ -35,6 +36,7 @@ export const defaultStudioSettings: StudioSettings = {
3536
claudeToken: '',
3637
theme: 'system',
3738
accentColor: 'blue',
39+
showUsageMeter: false,
3840
editorFontSize: 13,
3941
editorWordWrap: true,
4042
editorMinimap: false,

src/routes/__root.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { Toaster } from '@/components/ui/toast'
1818
import { OnboardingTour } from '@/components/onboarding/onboarding-tour'
1919
import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal'
2020
import { UpdateCenterNotifier } from '@/components/update-center-notifier'
21-
import { initializeSettingsAppearance } from '@/hooks/use-settings'
21+
import { initializeSettingsAppearance, useSettings } from '@/hooks/use-settings'
2222
import { useApplyChatWidth } from '@/hooks/use-chat-settings'
2323
import {
2424
ClaudeOnboarding,
@@ -251,6 +251,7 @@ export async function registerAppServiceWorker({
251251
}
252252

253253
function RootLayout() {
254+
const { settings } = useSettings()
254255
const pathname = useRouterState({ select: (state) => state.location.pathname })
255256
const isHermesWorldLandingRoute =
256257
pathname === '/hermes-world' ||
@@ -369,10 +370,8 @@ function RootLayout() {
369370
</ErrorBoundary>
370371
</WorkspaceShell>
371372
{!isHermesWorldLandingRoute ? <SearchModal /> : null}
372-
{/* UsageMeter must be mounted at root so the OPEN_USAGE event from
373-
the search modal's Usage tile has a listener. See #258.
374-
But public launch surfaces like HermesWorld should not show app usage chrome. */}
375-
{!isGameSurfaceRoute ? <UsageMeter /> : null}
373+
{/* Keep UsageMeter mounted so search-modal OPEN_USAGE still works even when the pill is hidden by default. */}
374+
{!isGameSurfaceRoute ? <UsageMeter visible={settings.showUsageMeter} /> : null}
376375
{!isHermesWorldLandingRoute ? <KeyboardShortcutsModal /> : null}
377376
{!isHermesWorldLandingRoute ? <UpdateCenterNotifier /> : null}
378377
{rootSurfaceState.showPostOnboardingOverlays && !isGameSurfaceRoute ? (

src/routes/settings/index.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,18 @@ function ChatDisplaySection() {
875875
aria-label="Expand sidebar on hover"
876876
/>
877877
</SettingsRow>
878+
<SettingsRow
879+
label="Show usage meter"
880+
description="Show the floating usage/provider pill in chat. Off by default to keep the composer clean."
881+
>
882+
<Switch
883+
checked={settings.showUsageMeter}
884+
onCheckedChange={(checked) =>
885+
updateSettings({ showUsageMeter: checked })
886+
}
887+
aria-label="Show usage meter"
888+
/>
889+
</SettingsRow>
878890
</SettingsSection>
879891
{/* Mobile Navigation removed — not relevant for Hermes Workspace */}
880892
</>

0 commit comments

Comments
 (0)