Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ This file holds the project facts that are easy to get wrong.
bootstrap's first `savePluginSettings` (test-checked), or every update would look like a new install.
- Help (the side-nav ?) opens the in-app FAQ (`help-dialog.tsx`), with GitHub Issues as its last link. Its answers
take setting names as placeholders filled from the settings' own labels: rename a setting and the FAQ follows.
- Progress bars hatch the usage spent since the open panel last showed them (`use-unseen-usage.ts`): it blinks
3.5 s, then the fill grows over it. Seen values are in memory, per plugin + line and in used terms (switching
used/left isn't spending). "Open" is asked from the OS (`isVisible`): the hidden panel's webview reports itself
visible before its first show.
- Ring logos are sized by `measureLogoExtent` (how far the logo's pixels reach) so square logos stay
inside the ring and round ones grow.
- Themes: `dark` (default, pure black: classes `.dark.oled`) | `light`. `useSettingsTheme` also sets the
Expand Down
22 changes: 22 additions & 0 deletions src/components/provider-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,28 @@ describe("ProviderCard", () => {
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "58")
})

it("hatches usage spent since the bar was last seen, per plugin and line, in both display modes", () => {
const card = (used: number, displayMode: "used" | "left" = "used") => (
<ProviderCard
name="Unseen"
pluginId="unseen-card"
displayMode={displayMode}
lines={[{ type: "progress", label: "Session", used, limit: 100, format: { kind: "percent" } }]}
/>
)
const hatch = () => document.querySelector<HTMLElement>('[data-slot="progress-unseen"]')
const { rerender } = render(card(10))
expect(hatch()).toBeNull()

rerender(card(25))
expect(hatch()?.style.left).toBe("10%")
expect(hatch()?.style.width).toBe("15%")

rerender(card(25, "left"))
expect(hatch()?.style.left).toBe("75%")
expect(hatch()?.style.width).toBe("15%")
})

it("uses elapsed-time marker position in displayMode=left", () => {
vi.useFakeTimers()
const now = new Date("2026-02-02T18:00:00.000Z")
Expand Down
14 changes: 14 additions & 0 deletions src/components/provider-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { UsageSparkline } from "@/components/usage-sparkline"
import { PluginError } from "@/components/plugin-error"
import { ProviderStatusBadge } from "@/components/provider-status-badge"
import { useNowTicker } from "@/hooks/use-now-ticker"
import { useUnseenUsage } from "@/hooks/use-unseen-usage"
import { REFRESH_COOLDOWN_MS, type DisplayMode, type ResetTimerDisplayMode, type TimeFormatMode } from "@/lib/settings"
import type { ManifestLine, MetricLine, PluginLink } from "@/lib/plugin-types"
import { groupLinesByType } from "@/lib/group-lines-by-type"
Expand Down Expand Up @@ -327,6 +328,7 @@ export function ProviderCard({
onResetTimerDisplayModeToggle={onResetTimerDisplayModeToggle}
now={now}
refreshing={isRefreshingWithData}
pluginId={pluginId}
/>
))}
</div>
Expand All @@ -343,6 +345,7 @@ export function ProviderCard({
onResetTimerDisplayModeToggle={onResetTimerDisplayModeToggle}
now={now}
refreshing={isRefreshingWithData}
pluginId={pluginId}
/>
))}
</Fragment>
Expand All @@ -366,6 +369,7 @@ function MetricLineRenderer({
onResetTimerDisplayModeToggle,
now,
refreshing,
pluginId,
}: {
line: MetricLine
usageUrl?: string
Expand All @@ -375,7 +379,14 @@ function MetricLineRenderer({
onResetTimerDisplayModeToggle?: () => void
now: number
refreshing?: boolean
pluginId?: string
}) {
const isProgress = line.type === "progress"
const unseen = useUnseenUsage(
isProgress && pluginId ? `${pluginId}|${line.label}` : null,
isProgress ? getDisplayedUsage(line.used, line.limit, "used").fraction * 100 : 0
)

if (line.type === "text" && line.resetCredits) {
return <ResetCredits credits={line.resetCredits} now={now} usageUrl={usageUrl} />
}
Expand Down Expand Up @@ -439,6 +450,7 @@ function MetricLineRenderer({
const hasTimeMarkerContext = hasPaceContext && periodDurationMs! > 0
const { amount: shownAmount, fraction: shownFraction } = getDisplayedUsage(line.used, line.limit, displayMode)
const percent = Math.round(shownFraction * 10000) / 100
const shownUnseen = unseen && displayMode === "left" ? { ...unseen, from: 100 - unseen.from } : unseen
const amountText =
line.format.kind === "percent"
? `${Math.round(shownAmount)}%`
Expand Down Expand Up @@ -529,6 +541,7 @@ function MetricLineRenderer({
value={percent}
indicatorColor={line.color}
markerValue={paceMarkerValue}
unseen={shownUnseen}
refreshing={refreshing}
/>
</div>
Expand All @@ -543,6 +556,7 @@ function MetricLineRenderer({
value={percent}
indicatorColor={line.color}
markerValue={paceMarkerValue}
unseen={shownUnseen}
refreshing={refreshing}
/>
)}
Expand Down
24 changes: 21 additions & 3 deletions src/components/ui/progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@ interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
indicatorColor?: string
markerValue?: number
refreshing?: boolean
/** Usage spent unseen, `from` in this bar's own terms (100 - used when it shows what's left). */
unseen?: { from: number; settling: boolean } | null
}

const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, indicatorColor, markerValue, refreshing, ...props }, ref) => {
({ className, value = 0, indicatorColor, markerValue, refreshing, unseen, ...props }, ref) => {
const clamped = Math.min(100, Math.max(0, value))
const unseenFrom = unseen ? Math.min(100, Math.max(0, unseen.from)) : clamped
const hatchStart = Math.min(unseenFrom, clamped)
const hatchWidth = Math.abs(unseenFrom - clamped)
// The fill waits at the start of the spent part while it blinks, then grows over it.
const fillWidth = unseen && !unseen.settling ? hatchStart : clamped
const clampedMarker =
typeof markerValue === "number" && Number.isFinite(markerValue)
? Math.min(100, Math.max(0, markerValue))
Expand Down Expand Up @@ -46,9 +53,20 @@ const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
{...props}
>
<div
className="h-full transition-all bg-primary"
style={{ width: `${clamped}%`, ...indicatorStyle }}
className={cn("h-full transition-all bg-primary", unseen?.settling && "duration-700 ease-out")}
style={{ width: `${fillWidth}%`, ...indicatorStyle }}
/>
{unseen && hatchWidth > 0 && (
<div
data-slot="progress-unseen"
aria-hidden="true"
className={cn(
"progress-unseen absolute inset-y-0 text-primary transition-opacity duration-700",
unseen.settling ? "opacity-0" : "animate-unseen"
)}
style={{ left: `${hatchStart}%`, width: `${hatchWidth}%`, color: indicatorColor }}
/>
)}
{showMarker && (
<div
data-slot="progress-marker"
Expand Down
27 changes: 27 additions & 0 deletions src/components/ui/ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,33 @@ describe("ui components", () => {
expect(container.querySelector('[data-slot="progress-refreshing"]')).toBeNull()
})

it("draws usage spent unseen hatched, then fills over it", () => {
const { container, rerender } = render(
<Progress value={30} indicatorColor="#f00" unseen={{ from: 10, settling: false }} />
)
const fill = () => container.querySelector<HTMLElement>('[role="progressbar"] > div')!
const hatch = () => container.querySelector<HTMLElement>('[data-slot="progress-unseen"]')
expect(fill().style.width).toBe("10%")
expect(hatch()?.style.left).toBe("10%")
expect(hatch()?.style.width).toBe("20%")
expect(hatch()).toHaveClass("animate-unseen")

rerender(<Progress value={30} indicatorColor="#f00" unseen={{ from: 10, settling: true }} />)
expect(fill().style.width).toBe("30%")
expect(hatch()).toHaveClass("opacity-0")
expect(hatch()).not.toHaveClass("animate-unseen")

// Showing what's left: the spent part is the piece the fill lost.
rerender(<Progress value={50} unseen={{ from: 70, settling: false }} />)
expect(fill().style.width).toBe("50%")
expect(hatch()?.style.left).toBe("50%")
expect(hatch()?.style.width).toBe("20%")

rerender(<Progress value={30} />)
expect(fill().style.width).toBe("30%")
expect(hatch()).toBeNull()
})

it("renders separator orientations", () => {
const { rerender } = render(<Separator />)
expect(screen.getByRole("separator")).toBeInTheDocument()
Expand Down
122 changes: 122 additions & 0 deletions src/hooks/use-unseen-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { act, renderHook } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

const { isVisibleMock } = vi.hoisted(() => ({ isVisibleMock: vi.fn() }))

vi.mock("@tauri-apps/api/core", () => ({
isTauri: () => true,
}))

vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({ isVisible: isVisibleMock }),
}))

import { UNSEEN_BLINK_MS, UNSEEN_SETTLE_MS, useUnseenUsage } from "@/hooks/use-unseen-usage"

// Lets the OS visibility answer reach the hook.
const settle = () =>
act(async () => {
for (let i = 0; i < 5; i++) await Promise.resolve()
})

async function setPanel(open: boolean) {
isVisibleMock.mockResolvedValue(open)
act(() => {
window.dispatchEvent(new Event(open ? "focus" : "blur"))
})
await settle()
}

function advance(ms: number) {
act(() => vi.advanceTimersByTime(ms))
}

// Seen values outlive a mount (as they outlive a page switch), so every test has its own key.
async function mount(key: string, used: number) {
const view = renderHook(({ used }) => useUnseenUsage(key, used), { initialProps: { used } })
await settle()
return view
}

describe("useUnseenUsage", () => {
beforeEach(() => {
vi.useFakeTimers()
isVisibleMock.mockReset().mockResolvedValue(false)
})

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

it("shows what was spent while the panel was closed, blinks, fills, then counts it as seen", async () => {
const { result, rerender } = await mount("spent", 10)
expect(result.current).toBeNull()

rerender({ used: 25 })
expect(result.current).toEqual({ from: 10, settling: false })
advance(60 * 60_000)
expect(result.current).toEqual({ from: 10, settling: false })

await setPanel(true)
advance(UNSEEN_BLINK_MS - 1)
expect(result.current).toEqual({ from: 10, settling: false })
advance(1)
expect(result.current).toEqual({ from: 10, settling: true })
advance(UNSEEN_SETTLE_MS)
expect(result.current).toBeNull()

await setPanel(false)
await setPanel(true)
expect(result.current).toBeNull()
})

it("doesn't count a reset as spent usage", async () => {
const { result, rerender } = await mount("reset", 60)
rerender({ used: 5 })
expect(result.current).toBeNull()
rerender({ used: 8 })
expect(result.current).toEqual({ from: 5, settling: false })
})

it("counts a drop as a reset even when it stays above what was seen", async () => {
const { result, rerender } = await mount("reset-above-seen", 10)
rerender({ used: 60 })
expect(result.current).toEqual({ from: 10, settling: false })
rerender({ used: 30 })
expect(result.current).toBeNull()
rerender({ used: 35 })
expect(result.current).toEqual({ from: 30, settling: false })
})

it("keeps it for the next open when the panel closes mid-blink", async () => {
await setPanel(true)
const { result, rerender } = await mount("closed-early", 10)
rerender({ used: 20 })
advance(2_000)
await setPanel(false)
advance(10_000)
expect(result.current).toEqual({ from: 10, settling: false })

await setPanel(true)
advance(UNSEEN_BLINK_MS + UNSEEN_SETTLE_MS)
expect(result.current).toBeNull()
})

it("starts over when more is spent while it blinks", async () => {
await setPanel(true)
const { result, rerender } = await mount("more", 10)
rerender({ used: 20 })
advance(3_000)
rerender({ used: 30 })
advance(3_000)
expect(result.current).toEqual({ from: 10, settling: false })
advance(UNSEEN_BLINK_MS - 3_000 + UNSEEN_SETTLE_MS)
expect(result.current).toBeNull()
})

it("draws nothing without a key", async () => {
const { result } = renderHook(() => useUnseenUsage(null, 50))
await settle()
expect(result.current).toBeNull()
})
})
88 changes: 88 additions & 0 deletions src/hooks/use-unseen-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useEffect, useReducer, useState, useSyncExternalStore } from "react"
import { isTauri } from "@tauri-apps/api/core"
import { getCurrentWindow } from "@tauri-apps/api/window"

export const UNSEEN_BLINK_MS = 3_500
export const UNSEEN_SETTLE_MS = 700

/** Usage a bar gained while nobody looked: `from` is the used percent last seen in the open panel. */
export type UnseenUsage = { from: number; settling: boolean }

// Used percent each bar last showed in the open panel, by plugin + line. Page switches keep it; a
// restart starts over from the first data.
const seenUsed = new Map<string, number>()
// The last usage each bar got, seen or not: dropping below it is a reset, even when it stays above
// what was seen.
const latestUsed = new Map<string, number>()

// Asked from the OS: before its first show the hidden panel's webview reports itself visible.
let panelOpen = false
const listeners = new Set<() => void>()

function checkPanel() {
getCurrentWindow()
.isVisible()
.then((open) => {
if (open === panelOpen) return
panelOpen = open
for (const listener of listeners) listener()
})
.catch((error) => console.error("Failed to read the panel's visibility:", error))
}

const PANEL_EVENTS = ["focus", "blur"] as const

function subscribePanel(listener: () => void) {
if (listeners.size === 0 && isTauri()) {
for (const event of PANEL_EVENTS) window.addEventListener(event, checkPanel)
document.addEventListener("visibilitychange", checkPanel)
checkPanel()
}
listeners.add(listener)
return () => {
listeners.delete(listener)
if (listeners.size > 0) return
for (const event of PANEL_EVENTS) window.removeEventListener(event, checkPanel)
document.removeEventListener("visibilitychange", checkPanel)
}
}

/**
* The usage `key`'s bar gained since the open panel last showed it. It is drawn while the panel is
* hidden too, so opening the panel doesn't first show the new fill and then take it back; once the
* panel is open it blinks, then the fill grows over it and it counts as seen.
*/
export function useUnseenUsage(key: string | null, used: number): UnseenUsage | null {
const open = useSyncExternalStore(subscribePanel, () => panelOpen)
const [settling, setSettling] = useState(false)
// `seenUsed` lives outside React: re-render when it changes, even if `settling` ends where it began.
const [, markSeen] = useReducer((count: number) => count + 1, 0)
const seen = key === null ? undefined : seenUsed.get(key)
const latest = key === null ? undefined : latestUsed.get(key)
const isReset = latest !== undefined && used < latest
const from = seen !== undefined && used > seen && !isReset ? seen : null

useEffect(() => {
if (key === null) return
latestUsed.set(key, used)
if (from === null) {
// First data, or a reset: nothing was spent unseen, so it starts from here.
seenUsed.set(key, used)
return
}
setSettling(false)
if (!open) return
const settle = window.setTimeout(() => setSettling(true), UNSEEN_BLINK_MS)
const done = window.setTimeout(() => {
seenUsed.set(key, used)
setSettling(false)
markSeen()
}, UNSEEN_BLINK_MS + UNSEEN_SETTLE_MS)
return () => {
window.clearTimeout(settle)
window.clearTimeout(done)
}
}, [key, used, from, open])

return from === null ? null : { from, settling }
}
Loading
Loading