fix(desktop): prevent timers from shifting as they count (#68131)

LiveDuration returned a bare string with proportional digits, so the
statusbar reflowed every second as the timer ticked. Extract a shared
StableText component that renders each character in its own 1ch-wide
cell, preventing any digit from shifting the layout — works with the
proportional sans font, no need for font-mono.

Both LiveDuration (statusbar session/running timers) and
ActivityTimerText (tool activity timers) now use StableText, dropping
the font-mono + tabular-nums workaround from the latter.

Also renames statusbar.ts → statusbar.tsx since LiveDuration now
returns JSX.
This commit is contained in:
ethernet 2026-07-20 12:39:20 -04:00 committed by GitHub
parent 9403b4f8ba
commit 470c7e2a60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 36 additions and 7 deletions

View file

@ -36,7 +36,7 @@ describe('ResponseLoadingIndicator timer', () => {
const sessionA = renderIndicator()
act(() => vi.advanceTimersByTime(5_000))
expect(screen.getByText('5s')).toBeTruthy()
expect(screen.getAllByText((_, node) => node?.textContent === '5s').length).toBeGreaterThan(0)
sessionA.unmount()
$activeSessionId.set('session-b')
@ -44,13 +44,13 @@ describe('ResponseLoadingIndicator timer', () => {
const sessionB = renderIndicator()
act(() => vi.advanceTimersByTime(3_000))
expect(screen.getByText('3s')).toBeTruthy()
expect(screen.getAllByText((_, node) => node?.textContent === '3s').length).toBeGreaterThan(0)
sessionB.unmount()
$activeSessionId.set('session-a')
$turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime())
renderIndicator()
expect(screen.getByText('8s')).toBeTruthy()
expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0)
})
})

View file

@ -1,6 +1,7 @@
import { cn } from '@/lib/utils'
import { formatElapsed } from './activity-timer'
import { StableText } from './stable-text'
interface ActivityTimerTextProps {
seconds: number
@ -9,16 +10,16 @@ interface ActivityTimerTextProps {
export function ActivityTimerText({ seconds, className }: ActivityTimerTextProps) {
return (
<span
<StableText
className={cn(
// Tinted with --dt-midground (very low alpha) so the timer reads
// as part of the same "live signal" cluster as the dither block /
// arc-border / working-session dot, instead of being neutral chrome.
'shrink-0 font-mono text-[0.56rem] leading-none tracking-[0.02em] text-midground/55 tabular-nums',
'shrink-0 text-[0.56rem] leading-none tracking-[0.02em] text-midground/55',
className
)}
>
{formatElapsed(seconds)}
</span>
</StableText>
)
}

View file

@ -0,0 +1,23 @@
import { cn } from '@/lib/utils'
interface StableTextProps {
children: string
className?: string
}
/**
* Renders text as a row of 1ch-wide cells so individual characters can't
* shift the layout as they change (e.g. digits in a ticking timer).
* Works with any proportional font no need for font-mono.
*/
export function StableText({ children, className }: StableTextProps) {
return (
<span className={cn('inline-flex', className)}>
{children.split('').map((char, i) => (
<span className="inline-block w-[1ch] text-center" key={i}>
{char}
</span>
))}
</span>
)
}

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { StableText } from '@/components/chat/stable-text'
import { compactNumber } from '@/lib/format'
import type { UsageStats } from '@/types/hermes'
@ -72,5 +73,9 @@ export function LiveDuration({ since }: { since: number | null | undefined }) {
return () => window.clearInterval(timer)
}, [since])
return since ? formatDuration(now - since) : null
if (!since) {
return null
}
return <StableText>{formatDuration(now - since)}</StableText>
}