Skip to content

Commit 816fcef

Browse files
cmsparksclaude
andcommitted
fix(web): retry failed recent instances, keep usage selections across scope, reload on project switch
- Profile cards show an error with Retry when recent instances fail to load, and retry on reopen - Org and project usage pages share range, measure, and breakdown for the session - Project usage remounts per project so it shows a loading state instead of stale totals Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4a6351d commit 816fcef

5 files changed

Lines changed: 112 additions & 22 deletions

File tree

‎frontend/apps/web/src/components/agents/AgentProfilesSection.tsx‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { agentStatusLabel } from '@/lib/agent-status'
3939
import { formatCount, formatTimeAgo } from '@/lib/format'
4040
import { isInsufficientCreditsError } from '@/lib/insufficient-credits'
4141
import { canManageOrg } from '@/lib/permissions'
42+
import { errorMessage } from '@/lib/submit-status'
4243
import { useActiveOrg } from '@/lib/use-active-org'
4344
import { useWebConfig } from '@/lib/web-config'
4445

@@ -301,6 +302,24 @@ function ProfileCard({
301302
open: expanded,
302303
content: instances.isPending ? (
303304
<p className="text-muted-foreground border-t px-4 py-3 text-xs">Loading…</p>
305+
) : instances.isError ? (
306+
<div className="flex items-center justify-between gap-3 border-t px-4 py-2">
307+
<p className="text-destructive text-xs" role="alert">
308+
{errorMessage(instances.error, 'Could not load recent instances.')}
309+
</p>
310+
<Button
311+
type="button"
312+
size="sm"
313+
variant="ghost"
314+
className="h-7 px-2"
315+
loading={instances.isFetching}
316+
onClick={() => {
317+
void instances.refetch()
318+
}}
319+
>
320+
Retry
321+
</Button>
322+
</div>
304323
) : (
305324
<RecentAgentList
306325
projectId={projectId}
@@ -367,7 +386,9 @@ function ProfileCard({
367386
}}
368387
onToggle={() => {
369388
setWantsRecent(true)
370-
setExpanded((open) => !open)
389+
// Reopening after a failed load tries again.
390+
if (!expanded && instances.isError) void instances.refetch()
391+
setExpanded(!expanded)
371392
}}
372393
/>
373394
) : (

‎frontend/apps/web/src/components/usage/UsageTimeseriesPanel.tsx‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export type UsageTimeseriesScope = Omit<
2222
'timezone' | 'metric' | 'groupBy' | 'groupLimit'
2323
>
2424

25+
export interface UsageChartSelection {
26+
measure: UsageMeasure
27+
breakdown: UsageBreakdown
28+
}
29+
2530
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
2631

2732
const breakdowns: { value: UsageBreakdown; label: string }[] = [
@@ -40,15 +45,27 @@ export function UsageTimeseriesPanel({
4045
filters,
4146
summary,
4247
showControls = true,
48+
selection,
49+
onSelectionChange,
4350
}: {
4451
orgId: string
4552
filters: UsageTimeseriesScope
4653
summary?: (timeseries: UsageTimeseries) => ReactNode
4754
/** Measure and breakdown toggles; without them the chart shows tokens by model. */
4855
showControls?: boolean
56+
/** Controls the measure and breakdown from outside, so they outlive this panel. */
57+
selection?: UsageChartSelection
58+
onSelectionChange?: (patch: Partial<UsageChartSelection>) => void
4959
}) {
50-
const [breakdown, setBreakdown] = useState<UsageBreakdown>('model')
51-
const [measure, setMeasure] = useState<UsageMeasure>('tokens')
60+
const [localSelection, setLocalSelection] = useState<UsageChartSelection>({
61+
measure: 'tokens',
62+
breakdown: 'model',
63+
})
64+
const { measure, breakdown } = selection ?? localSelection
65+
function select(patch: Partial<UsageChartSelection>) {
66+
if (onSelectionChange) onSelectionChange(patch)
67+
else setLocalSelection((current) => ({ ...current, ...patch }))
68+
}
5269
const query = useUsageTimeseries(orgId, {
5370
...filters,
5471
timezone: browserTimezone,
@@ -86,13 +103,17 @@ export function UsageTimeseriesPanel({
86103
label="Usage measure"
87104
value={measure}
88105
options={measures}
89-
onChange={setMeasure}
106+
onChange={(next) => {
107+
select({ measure: next })
108+
}}
90109
/>
91110
<UsageToggle
92111
label="Break usage down by"
93112
value={breakdown}
94113
options={breakdowns}
95-
onChange={setBreakdown}
114+
onChange={(next) => {
115+
select({ breakdown: next })
116+
}}
96117
/>
97118
</div>
98119
)}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useSyncExternalStore } from 'react'
2+
3+
import type { UsageBreakdown, UsageMeasure } from '@/components/usage/usage-chart-data'
4+
import { defaultUsageRange, type UsageDateRange } from '@/components/usage/usage-date-range'
5+
6+
export interface UsageViewState {
7+
range: UsageDateRange
8+
measure: UsageMeasure
9+
breakdown: UsageBreakdown
10+
}
11+
12+
/**
13+
* The org and project usage pages share one selection for the session, so switching
14+
* between all projects and a single project keeps the range, measure, and breakdown.
15+
*/
16+
let state: UsageViewState = { range: defaultUsageRange(), measure: 'tokens', breakdown: 'model' }
17+
const listeners = new Set<() => void>()
18+
19+
export function useUsageViewState() {
20+
return useSyncExternalStore(subscribe, getSnapshot)
21+
}
22+
23+
export function updateUsageViewState(patch: Partial<UsageViewState>) {
24+
state = { ...state, ...patch }
25+
for (const listener of listeners) listener()
26+
}
27+
28+
function subscribe(onStoreChange: () => void) {
29+
listeners.add(onStoreChange)
30+
return () => {
31+
listeners.delete(onStoreChange)
32+
}
33+
}
34+
35+
function getSnapshot() {
36+
return state
37+
}

‎frontend/apps/web/src/routes/OrganizationUsagePage.tsx‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
import { useOrgUsage } from '@omnara/react'
22
import { Link } from '@tanstack/react-router'
3-
import { useState } from 'react'
43

54
import { Button } from '@/components/ui/button'
6-
import {
7-
defaultUsageDays,
8-
defaultUsageRange,
9-
isDefaultUsageRange,
10-
} from '@/components/usage/usage-date-range'
5+
import { defaultUsageDays, isDefaultUsageRange } from '@/components/usage/usage-date-range'
6+
import { updateUsageViewState, useUsageViewState } from '@/components/usage/usage-view-state'
117
import { UsageDateRangeMenu } from '@/components/usage/UsageDateRangeMenu'
128
import { UsageReportView } from '@/components/usage/UsageReport'
139
import { UsageTimeseriesPanel } from '@/components/usage/UsageTimeseriesPanel'
1410
import { useActiveOrg } from '@/lib/use-active-org'
1511

1612
export function OrganizationUsagePage() {
1713
const { activeOrg } = useActiveOrg()
18-
const [range, setRange] = useState(defaultUsageRange)
14+
const view = useUsageViewState()
15+
const range = view.range
1916
const query = useOrgUsage(activeOrg.id, range.window)
2017
const filtered = !isDefaultUsageRange(range)
2118

@@ -24,14 +21,21 @@ export function OrganizationUsagePage() {
2421
<section className="flex flex-col gap-4">
2522
<div className="flex flex-wrap items-center justify-between gap-3">
2623
<h1 className="type-title">Usage</h1>
27-
<UsageDateRangeMenu value={range} onChange={setRange} />
24+
<UsageDateRangeMenu
25+
value={range}
26+
onChange={(next) => {
27+
updateUsageViewState({ range: next })
28+
}}
29+
/>
2830
</div>
2931
<UsageReportView
3032
query={query}
3133
chart={
3234
<UsageTimeseriesPanel
3335
orgId={activeOrg.id}
3436
filters={{ ...range.window, interval: range.interval }}
37+
selection={view}
38+
onSelectionChange={updateUsageViewState}
3539
/>
3640
}
3741
emptyMessage={

‎frontend/apps/web/src/routes/ProjectUsagePage.tsx‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,40 @@
11
import { useProjectUsage } from '@omnara/react'
22
import { Link } from '@tanstack/react-router'
3-
import { useState } from 'react'
43

54
import { ProjectPageFrame } from '@/components/projects/ProjectPageFrame'
65
import { Button } from '@/components/ui/button'
7-
import {
8-
defaultUsageDays,
9-
defaultUsageRange,
10-
isDefaultUsageRange,
11-
} from '@/components/usage/usage-date-range'
6+
import { defaultUsageDays, isDefaultUsageRange } from '@/components/usage/usage-date-range'
7+
import { updateUsageViewState, useUsageViewState } from '@/components/usage/usage-view-state'
128
import { UsageDateRangeMenu } from '@/components/usage/UsageDateRangeMenu'
139
import { UsageReportView } from '@/components/usage/UsageReport'
1410
import { UsageTimeseriesPanel } from '@/components/usage/UsageTimeseriesPanel'
1511

1612
export function ProjectUsagePage() {
1713
return (
1814
<ProjectPageFrame>
19-
{({ activeOrg, projectId }) => <ProjectUsage orgId={activeOrg.id} projectId={projectId} />}
15+
{({ activeOrg, projectId }) => (
16+
// Keyed so a project switch shows a loading state, not the last project's usage.
17+
<ProjectUsage key={projectId} orgId={activeOrg.id} projectId={projectId} />
18+
)}
2019
</ProjectPageFrame>
2120
)
2221
}
2322

2423
function ProjectUsage({ orgId, projectId }: { orgId: string; projectId: string }) {
25-
const [range, setRange] = useState(defaultUsageRange)
24+
const view = useUsageViewState()
25+
const range = view.range
2626
const query = useProjectUsage(orgId, projectId, range.window)
2727
const filtered = !isDefaultUsageRange(range)
2828
return (
2929
<section className="flex flex-col gap-4">
3030
<div className="flex flex-wrap items-center justify-between gap-3">
3131
<h1 className="type-title">Usage</h1>
32-
<UsageDateRangeMenu value={range} onChange={setRange} />
32+
<UsageDateRangeMenu
33+
value={range}
34+
onChange={(next) => {
35+
updateUsageViewState({ range: next })
36+
}}
37+
/>
3338
</div>
3439
<UsageReportView
3540
query={query}
@@ -41,6 +46,8 @@ function ProjectUsage({ orgId, projectId }: { orgId: string; projectId: string }
4146
interval: range.interval,
4247
projectIDs: [projectId],
4348
}}
49+
selection={view}
50+
onSelectionChange={updateUsageViewState}
4451
/>
4552
}
4653
emptyMessage={

0 commit comments

Comments
 (0)