mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
perf(app): stop the per-second shell re-render and halve the splash video (#1352)
AppMain owned a wall-clock setInterval(1000) that existed only so the footer could print "refreshed Ns ago", so every tick reconciled the sidebar, hero, daily chart, heatmap and every table 60 times a minute. The per-second tick now lives in a leaf RefreshedAt component that owns just that label. The shell re-renders on real state changes plus a 15-second day check that fires only when the local calendar rolls over, which the overview memo keys need to keep "Today" honest at midnight. App.renderChurn.test.tsx pins it: 10 renders over 10 idle seconds before, 0 after. The splash video is re-encoded at VP9 CRF 30, 2,582,836 to 1,294,904 bytes, same 544x544 and 6.04s.
This commit is contained in:
parent
9cd7dc5c98
commit
426fea3a23
4 changed files with 177 additions and 9 deletions
|
|
@ -57,6 +57,12 @@
|
|||
- **The popover's quota warning can be read in light mode, and says which limit it is warning about.** The banner under the tagline painted its text in the same system yellow, orange or red as its 12% pill, so in light mode warning text measured 1.2:1 to 1.4:1 against the pill, and even red reached only 3.0:1; in dark mode red fell to 3.3:1. The pill keeps its hue, and the text and its glyph now take a per-appearance colour for each severity, a deep amber, rust or brick in light mode and a lifted orange or red in dark mode, that measures 5.3:1 or better everywhere. A test holds every severity at WCAG AA (4.5:1) against the pill composited over both ends of the popover's background in both appearances. The sentence also named no window. Its figure is the provider's worst window, so "Claude 70% of quota used" meant the 5-hour limit while Claude's own panel read weekly 34%, and it looked wrong. It now reads `Claude · 5-hour 71% · resets in 3h 12m`, with the countdown worded exactly as the Capacity Dock words it and one line per provider when several are warning. The countdown is left off when the provider reports no reset time or the reset has already passed. "Over limit" is kept for a window at or past 100%, where it used to cover anything from 90%. The flame's colour and the providers that trigger the banner are unchanged.
|
||||
- **The Trend tooltip shows the day's total again under its model breakdown.** Provider-filtered history now carries tokens, so with only Claude selected the tooltip header switched to a token count and the day's cost appeared nowhere, and the model rows could not stand in for it because only four of up to five models render. A Total row with the day's cost and tokens now closes the breakdown, as it does in the web dashboard, and the header figure it replaces is shown only for days without a breakdown. Fixes #1433.
|
||||
|
||||
### Changed (desktop)
|
||||
- **The desktop shell stops re-rendering the whole tree once a second.** AppMain owned a wall-clock `setInterval(1000)` that existed only so the footer could print "refreshed Ns ago", but every tick re-rendered the sidebar, the hero, the daily chart, the heatmap and every table 60 times a minute whether or not any data had changed. The per-second tick now lives in a leaf `RefreshedAt` component that owns just that label, and the shell itself re-renders only on real state changes plus a 15-second day check that fires exactly when the local calendar rolls over — that rollover matters because the overview memo keys bake in a today/month boundary, so midnight must produce one re-render to keep "Today" honest. Renderer render-count regressions are pinned by a churn test that fails if the shell re-renders on the per-second tick or on same-day clock checks.
|
||||
|
||||
### Fixed (desktop)
|
||||
- **The renderer ships half the splash video weight.** The cold-start splash video is re-encoded at VP9 CRF 30 (SSIM 0.994 against the source), taking `app/renderer/assets/splash-loader.webm` from 2,582,836 bytes to 1,294,904 bytes — about half.
|
||||
|
||||
## 0.9.24 - 2026-09-04
|
||||
|
||||
### Added
|
||||
|
|
|
|||
137
app/renderer/App.renderChurn.test.tsx
Normal file
137
app/renderer/App.renderChurn.test.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// @vitest-environment jsdom
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { App } from './App'
|
||||
|
||||
// Render-churn regression: the shell must NOT re-render on a wall-clock tick.
|
||||
// The footer's "refreshed Ns ago" label is the only per-second surface and it
|
||||
// owns its own 1s interval (RefreshedAt); AppMain re-renders only on real state
|
||||
// changes and once per local calendar day (memo-key midnight boundary).
|
||||
|
||||
const stored = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => stored.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => stored.set(key, value),
|
||||
removeItem: (key: string) => stored.delete(key),
|
||||
key: (index: number) => [...stored.keys()][index] ?? null,
|
||||
get length() { return stored.size },
|
||||
clear: () => stored.clear(),
|
||||
})
|
||||
|
||||
const churn = vi.hoisted(() => {
|
||||
const state = { overviewRenders: 0 }
|
||||
function payload() {
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
current: {
|
||||
label: 'Last 30 days',
|
||||
cost: 12.34,
|
||||
calls: 12,
|
||||
sessions: 2,
|
||||
oneShotRate: null,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
cacheHitPercent: 0,
|
||||
codexCredits: 0,
|
||||
topActivities: [],
|
||||
topModels: [],
|
||||
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
|
||||
providers: { claude: 10, codex: 2 },
|
||||
topProjects: [],
|
||||
topSessions: [],
|
||||
retryTax: { totalUSD: 0 },
|
||||
routingWaste: { totalSavingsUSD: 0 },
|
||||
},
|
||||
history: { daily: [] },
|
||||
optimize: { savingsUSD: 0, topFindings: [] },
|
||||
}
|
||||
}
|
||||
const bridge = {
|
||||
getOverview: vi.fn(async () => payload()),
|
||||
getModels: vi.fn(async () => null),
|
||||
telemetryTrack: vi.fn(async () => true),
|
||||
getUpdateStatus: vi.fn(async () => null),
|
||||
onProgress: vi.fn(() => () => {}),
|
||||
onUpdateStatus: vi.fn(() => () => {}),
|
||||
}
|
||||
return { state, bridge }
|
||||
})
|
||||
|
||||
vi.mock('./lib/ipc', () => ({ codeburn: churn.bridge }))
|
||||
|
||||
vi.mock('./sections/Overview', () => ({
|
||||
// Probe component: every call is one AppMain render (App renders it inline).
|
||||
OverviewContent: () => { churn.state.overviewRenders += 1; return null },
|
||||
}))
|
||||
|
||||
function resetChurn(): void {
|
||||
churn.state.overviewRenders = 0
|
||||
churn.bridge.getOverview.mockClear()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stored.clear()
|
||||
resetChurn()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function bootApp(): Promise<void> {
|
||||
render(<App />)
|
||||
// Flush the boot overview fetch (a resolved promise; no timer advance needed).
|
||||
await act(async () => { await Promise.resolve() })
|
||||
}
|
||||
|
||||
describe('App render churn', () => {
|
||||
it('does not re-render the shell on the per-second footer tick', async () => {
|
||||
await bootApp()
|
||||
const baseline = churn.state.overviewRenders
|
||||
expect(baseline).toBeGreaterThan(0)
|
||||
// One advance per act so React cannot batch the whole window into a single
|
||||
// render: a wall clock in AppMain re-renders the shell once per second.
|
||||
for (let second = 0; second < 5; second++) {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
|
||||
}
|
||||
const growth = churn.state.overviewRenders - baseline
|
||||
expect(growth).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('still ticks the "refreshed Ns ago" label once a second while the shell stays flat', async () => {
|
||||
await bootApp()
|
||||
// The footer label is RefreshedAt's whole output: starts at "just now",
|
||||
// then counts seconds. Find the node whose text carries the label.
|
||||
const labelNode = () => Array.from(document.querySelectorAll('body *'))
|
||||
.find(node => (node.textContent ?? '').trim().startsWith('refreshed'))
|
||||
const first = labelNode()?.textContent?.trim()
|
||||
expect(first).toBe('refreshed just now')
|
||||
const shellRendersBeforeTick = churn.state.overviewRenders
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
|
||||
expect(labelNode()?.textContent?.trim()).toBe('refreshed 1s ago')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
|
||||
expect(labelNode()?.textContent?.trim()).toBe('refreshed 2s ago')
|
||||
// …while the shell did not re-render at all across those ticks.
|
||||
expect(churn.state.overviewRenders).toBe(shellRendersBeforeTick)
|
||||
})
|
||||
|
||||
it('rolls the shell exactly on the local midnight boundary, not on every check', async () => {
|
||||
vi.setSystemTime(new Date(2026, 8, 12, 23, 59, 40))
|
||||
await bootApp()
|
||||
const baseline = churn.state.overviewRenders
|
||||
// First 15s day-check lands at 23:59:55 — same day, no re-render.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(15_000) })
|
||||
expect(churn.state.overviewRenders - baseline).toBe(0)
|
||||
// Second check lands past midnight: the memo-key boundary must roll, which
|
||||
// needs a re-render (a handful, as the overview refetch clears then lands).
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(15_000) })
|
||||
const growth = churn.state.overviewRenders - (baseline + 0)
|
||||
expect(growth).toBeGreaterThanOrEqual(1)
|
||||
expect(growth).toBeLessThanOrEqual(5)
|
||||
})
|
||||
})
|
||||
|
|
@ -332,7 +332,22 @@ function AppMain() {
|
|||
// Combined reports unfiltered paired-device usage, so a project filter would
|
||||
// come back inside the aggregate. The filter wins, from the first poll.
|
||||
const scope: Scope = projectFiltered ? 'local' : requestedScope
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
// Rolls the shell once per local calendar day: the overview memo keys bake in
|
||||
// a today/month boundary, so midnight must produce a re-render — but ticking
|
||||
// a wall clock every second would re-render the whole tree for a label one
|
||||
// row wide, so the per-second "refreshed Ns ago" tick lives in RefreshedAt.
|
||||
const dayRef = useRef(localDateKey(new Date()))
|
||||
const [, bumpDay] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
const today = localDateKey(new Date())
|
||||
if (today !== dayRef.current) {
|
||||
dayRef.current = today
|
||||
bumpDay(n => n + 1)
|
||||
}
|
||||
}, 15_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
const [, setCurrencyTick] = useState(0)
|
||||
const [snapshotRevision, setSnapshotRevision] = useState(0)
|
||||
const configGenerationRef = useRef(0)
|
||||
|
|
@ -393,11 +408,11 @@ function AppMain() {
|
|||
// a provider/config filter, so onScopeChange forces provider='all' and clears
|
||||
// the config scope before this poll runs. Passing scope='local' produces the
|
||||
// same flag-free argv as before, so local users are unaffected.
|
||||
const activeOverviewKey = overviewMemoKey(provider, period, customRange, claudeConfigSource, scope, new Date(now))
|
||||
const activeOverviewKey = overviewMemoKey(provider, period, customRange, claudeConfigSource, scope, new Date())
|
||||
// Provider membership is period/range-specific. Keep the catalog tied to the
|
||||
// exact unscoped local overview that produced it so a scoped view cannot leak
|
||||
// providers from a different time horizon while its own payload is loading.
|
||||
const allProviderOverviewKey = overviewMemoKey('all', period, customRange, null, 'local', new Date(now))
|
||||
const allProviderOverviewKey = overviewMemoKey('all', period, customRange, null, 'local', new Date())
|
||||
const overview = usePolled<MenubarPayload>(
|
||||
() => scope === 'combined'
|
||||
? codeburn.getOverview(period, 'all', customRange ?? undefined, undefined, undefined, 'combined')
|
||||
|
|
@ -675,11 +690,6 @@ function AppMain() {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, period, provider, visibleProviderEntries, customRange, claudeConfigSource, scope, snapshotRevision, overview.data == null])
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const refreshVisible = useCallback(() => {
|
||||
refreshOverview()
|
||||
setRefreshToken(token => token + 1)
|
||||
|
|
@ -953,7 +963,7 @@ function AppMain() {
|
|||
{ k: shortcutLabel(','), label: 'Settings' },
|
||||
{ k: shortcutLabel('R'), label: 'Refresh' },
|
||||
]}
|
||||
right={<RefreshMark refreshing={refreshing} label={refreshedLabel(selectedLastSuccessAt, refreshing, now)} />}
|
||||
right={<RefreshedAt lastSuccessAt={selectedLastSuccessAt} refreshing={refreshing} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -961,6 +971,21 @@ function AppMain() {
|
|||
)
|
||||
}
|
||||
|
||||
/** The footer's "refreshed Ns ago" note. The only part of the shell that needs
|
||||
* a 1-second tick, so the tick lives here: a clock in AppMain would reconcile
|
||||
* the whole tree — sidebar, hero, chart, heatmap, tables — 60 times a minute
|
||||
* for a label one row wide. Props re-renders (a new lastSuccessAt) still land
|
||||
* immediately; the interval only repaints elapsed time. The RefreshMark it
|
||||
* renders keeps the fixed icon and screen-reader state main's footer added. */
|
||||
function RefreshedAt({ lastSuccessAt, refreshing }: { lastSuccessAt: number | null; refreshing: boolean }) {
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setTick(tick => tick + 1), 1000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
return <RefreshMark refreshing={refreshing} label={refreshedLabel(lastSuccessAt, refreshing, Date.now())} />
|
||||
}
|
||||
|
||||
/** Footer refresh state. The icon is always in the DOM at a fixed 12px so the
|
||||
* "refreshed Ns ago" text never moves between idle and in-flight, and it sits
|
||||
* LAST in a right-anchored row so the label re-flowing never shifts it. */
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue