Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/desktop/src/components/ui/glyph-spinner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { GlyphSpinner } from './glyph-spinner'

describe('GlyphSpinner', () => {
beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
})

it('renders a stable status glyph without scheduling animation work', () => {
render(<GlyphSpinner ariaLabel="Working" />)

const glyph = screen.getByRole('status', { name: 'Working' })

expect(glyph.textContent).toHaveLength(1)
expect(vi.getTimerCount()).toBe(0)
})
})
48 changes: 12 additions & 36 deletions apps/desktop/src/components/ui/glyph-spinner.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
import { useEffect, useState } from 'react'
import spinners, { type BrailleSpinnerName as SpinnerName } from 'unicode-animations'

import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { cn } from '@/lib/utils'

export type { SpinnerName }

interface NormalisedSpinner {
frames: readonly string[]
interval: number
}

// Some spinners ship multi-character frames. Pull the first cell so each
// frame fits in one monospace box — matches how the TUI uses them.
const FRAMES_BY_NAME: Record<SpinnerName, NormalisedSpinner> = (() => {
const out = {} as Record<SpinnerName, NormalisedSpinner>
// glyph fits in one monospace box, matching how the TUI uses them. Desktop
// deliberately keeps the first frame stable to avoid decorative React churn.
const GLYPH_BY_NAME: Record<SpinnerName, string> = (() => {
const out = {} as Record<SpinnerName, string>

for (const name of Object.keys(spinners) as SpinnerName[]) {
const raw = spinners[name]

out[name] = {
frames: raw.frames.map(frame => [...frame][0] ?? '⠀'),
interval: raw.interval
}
out[name] = [...(raw.frames[0] ?? '')][0] ?? '⠀'
}

return out
Expand All @@ -35,37 +26,22 @@ interface GlyphSpinnerProps {
}

/**
* One-char glyph spinner driven by `unicode-animations` (braille, orbit, scan,
* etc. — pick any `spinner` name). Mirrors the spinner used by the Ink TUI so
* the desktop and terminal experiences read the same visually. Renders inside
* an `inline-flex` cell with `leading-none` and `items-center` so it sits
* vertically centred inside its parent's line-box.
* One-char status glyph sampled from `unicode-animations` (braille, orbit,
* scan, etc. — pick any `spinner` name). The Desktop glyph stays static so a
* busy status does not schedule React updates; the Ink TUI remains animated.
* Renders inside an `inline-flex` cell with `leading-none` and `items-center`
* so it sits vertically centred inside its parent's line-box.
*/
export function GlyphSpinner({ ariaLabel = 'Loading', className, spinner = 'braille' }: GlyphSpinnerProps) {
const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.braille!
const [frame, setFrame] = useState(0)
// Pause when this surface is a hidden (kept-alive) tab: N mounted tabs each
// ticking a setInterval + setState burn CPU for pixels nobody can see.
const visible = usePaneVisible()

useEffect(() => {
if (!visible) {
return
}

setFrame(0)
const id = window.setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)

return () => window.clearInterval(id)
}, [spin, visible])
const glyph = GLYPH_BY_NAME[spinner] ?? GLYPH_BY_NAME.braille!

return (
<span
aria-label={ariaLabel}
className={cn('inline-flex items-center justify-center font-mono leading-none tabular-nums', className)}
role="status"
>
{spin.frames[frame]}
{glyph}
</span>
)
}