From 470c7e2a608e75b9ee61dd1f2e10599983ca97ec Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 12:39:20 -0400 Subject: [PATCH] fix(desktop): prevent timers from shifting as they count (#68131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../assistant-ui/thread/status.test.tsx | 6 ++--- .../components/chat/activity-timer-text.tsx | 7 +++--- .../src/components/chat/stable-text.tsx | 23 +++++++++++++++++++ .../src/lib/{statusbar.ts => statusbar.tsx} | 7 +++++- 4 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/components/chat/stable-text.tsx rename apps/desktop/src/lib/{statusbar.ts => statusbar.tsx} (92%) diff --git a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx index b4920e1ec483..f6edaee0d7e3 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx @@ -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) }) }) diff --git a/apps/desktop/src/components/chat/activity-timer-text.tsx b/apps/desktop/src/components/chat/activity-timer-text.tsx index aa439eb247d5..d9ca1c292e29 100644 --- a/apps/desktop/src/components/chat/activity-timer-text.tsx +++ b/apps/desktop/src/components/chat/activity-timer-text.tsx @@ -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 ( - {formatElapsed(seconds)} - + ) } diff --git a/apps/desktop/src/components/chat/stable-text.tsx b/apps/desktop/src/components/chat/stable-text.tsx new file mode 100644 index 000000000000..1e7e03815cd6 --- /dev/null +++ b/apps/desktop/src/components/chat/stable-text.tsx @@ -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 ( + + {children.split('').map((char, i) => ( + + {char} + + ))} + + ) +} diff --git a/apps/desktop/src/lib/statusbar.ts b/apps/desktop/src/lib/statusbar.tsx similarity index 92% rename from apps/desktop/src/lib/statusbar.ts rename to apps/desktop/src/lib/statusbar.tsx index b06f9cc0d60e..0c0d22599c58 100644 --- a/apps/desktop/src/lib/statusbar.ts +++ b/apps/desktop/src/lib/statusbar.tsx @@ -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 {formatDuration(now - since)} }