diff --git a/apps/desktop-tauri/src/components/QuotaBurndownChart.tsx b/apps/desktop-tauri/src/components/QuotaBurndownChart.tsx index 5c62a795910..4daeae42d3a 100644 --- a/apps/desktop-tauri/src/components/QuotaBurndownChart.tsx +++ b/apps/desktop-tauri/src/components/QuotaBurndownChart.tsx @@ -1,6 +1,7 @@ import type { QuotaBurndownSnapshot, QuotaBurndownPoint } from "../types/bridge"; import type { LocaleKey } from "../i18n/keys"; import { formatRelativeUpdated } from "../lib/relativeTime"; +import { useNow } from "../hooks/useNow"; const CHART_WIDTH = 300; const CHART_HEIGHT = 76; @@ -42,12 +43,14 @@ function polyline(points: QuotaBurndownPoint[], startMs: number, endMs: number): export default function QuotaBurndownChart({ burndown, t, - nowMs = Date.now(), + nowMs, }: { burndown: QuotaBurndownSnapshot; t: (key: LocaleKey) => string; nowMs?: number; }) { + // Called before the early returns below; keeps the capture age advancing. + const liveNowMs = useNow(); if (burndown.samples.length === 0) return null; const startMs = Date.parse(burndown.start); @@ -63,7 +66,7 @@ export default function QuotaBurndownChart({ const idealEndX = xFor(Date.parse(burndown.ideal[1].capturedAt), startMs, resetMs); const idealEndY = yFor(burndown.ideal[1].remainingPercent); const remaining = Math.round(last.remainingPercent); - const captureAge = formatRelativeUpdated(lastMs, t, nowMs); + const captureAge = formatRelativeUpdated(lastMs, t, nowMs ?? liveNowMs); return (
diff --git a/apps/desktop-tauri/src/components/card/CardHeader.test.tsx b/apps/desktop-tauri/src/components/card/CardHeader.test.tsx index 96739370a52..70b0248c6bb 100644 --- a/apps/desktop-tauri/src/components/card/CardHeader.test.tsx +++ b/apps/desktop-tauri/src/components/card/CardHeader.test.tsx @@ -125,6 +125,27 @@ describe("CardHeader", () => { expect(screen.getByRole("button", { name: "Copy error" })).toBeInTheDocument(); }); + it("advances the update subtitle while the data stays unchanged", () => { + vi.useFakeTimers(); + vi.setSystemTime(Date.parse("2026-10-11T12:00:00Z")); + render( + , + ); + expect(screen.getByText("Updated just now")).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(screen.getByText("Updated 1m ago")).toBeInTheDocument(); + }); + it("omits the email and plan when there are none", () => { const { container } = render( , diff --git a/apps/desktop-tauri/src/components/card/CardHeader.tsx b/apps/desktop-tauri/src/components/card/CardHeader.tsx index 0b1f2083699..77e7bbeb65d 100644 --- a/apps/desktop-tauri/src/components/card/CardHeader.tsx +++ b/apps/desktop-tauri/src/components/card/CardHeader.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { LocaleKey } from "../../i18n/keys"; import { useLocale } from "../../hooks/useLocale"; import { formatRelativeUpdated } from "../../lib/relativeTime"; +import { useNow } from "../../hooks/useNow"; type Translate = (key: LocaleKey) => string; @@ -105,6 +106,7 @@ export default function CardHeader({ isRefreshing: boolean; }) { const { t } = useLocale(); + const nowMs = useNow(); return (
@@ -130,7 +132,7 @@ export default function CardHeader({ ) : ( - {headerSubtitle(updatedAt, isRefreshing, t)} + {headerSubtitle(updatedAt, isRefreshing, t, nowMs)} )} {plan && {plan}} diff --git a/apps/desktop-tauri/src/hooks/useNow.test.ts b/apps/desktop-tauri/src/hooks/useNow.test.ts new file mode 100644 index 00000000000..459102d9b1c --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useNow.test.ts @@ -0,0 +1,45 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NOW_TICK_MS, useNow } from "./useNow"; + +describe("useNow", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(Date.parse("2026-10-11T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts at the current time and re-reads it every tick", () => { + const { result } = renderHook(() => useNow()); + expect(result.current).toBe(Date.parse("2026-10-11T12:00:00Z")); + + act(() => { + vi.advanceTimersByTime(NOW_TICK_MS); + }); + expect(result.current).toBe(Date.parse("2026-10-11T12:00:15Z")); + expect(result.current).toBe(Date.now()); + }); + + it("does not re-render before the first tick", () => { + const seen: number[] = []; + renderHook(() => { + seen.push(useNow()); + }); + const before = seen.length; + act(() => { + vi.advanceTimersByTime(NOW_TICK_MS - 1); + }); + expect(seen.length).toBe(before); + }); + + it("clears its interval on unmount", () => { + const clearSpy = vi.spyOn(window, "clearInterval"); + const { unmount } = renderHook(() => useNow()); + unmount(); + expect(clearSpy).toHaveBeenCalled(); + clearSpy.mockRestore(); + }); +}); diff --git a/apps/desktop-tauri/src/hooks/useNow.ts b/apps/desktop-tauri/src/hooks/useNow.ts new file mode 100644 index 00000000000..b0dc6a035e2 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useNow.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +/** Default re-read period for relative labels such as "1 minute ago". */ +export const NOW_TICK_MS = 15_000; + +/** + * The current time in Unix ms, re-read every `intervalMs` so relative + * timestamps ("Updated 1 minute ago") advance while the data stays unchanged. + * Each call owns one interval, cleared on unmount. + */ +export function useNow(intervalMs: number = NOW_TICK_MS): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), intervalMs); + return () => window.clearInterval(id); + }, [intervalMs]); + return now; +} diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index 1cdeef4a2c4..6b2b03ecd04 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -19,6 +19,7 @@ import { useProviderStatuses } from "./useProviderStatuses"; import { useSettings } from "./useSettings"; import { useUpdateState } from "./useUpdateState"; import { useLocale } from "./useLocale"; +import { useNow } from "./useNow"; import { useProviderSwitcherKeys } from "./useProviderSwitcherKeys"; import { useSurfaceTarget } from "./useSurfaceMode"; import { useTrayPanelLayout } from "./useTrayPanelLayout"; @@ -202,12 +203,15 @@ export function useTrayPanelController(state: BootstrapState) { }; }, [actionProviderId]); const serviceStatuses = useProviderStatuses(); + // Re-read the clock so "updated N ago" status text keeps advancing. + const nowMs = useNow(); // macOS `MenuDescriptor.statusLine`: the selected provider, else the first // enabled one, whether or not it has a usage snapshot yet. const statusLine = serviceStatusLine( serviceStatuses, selectedProviderId ?? denseProviderSlots[0]?.id ?? null, t, + nowMs, ); const links = providerLinks !== null && providerLinks.providerId === actionProviderId diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index a696e27203f..0cdba3f5c54 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -9,6 +9,7 @@ import { SettingsGroup } from "../../../components/FormControls"; import { formatRelativeUpdated } from "../../../lib/relativeTime"; import { nextEnabledProviders } from "../settingsTabs"; import { useLocale } from "../../../hooks/useLocale"; +import { useNow } from "../../../hooks/useNow"; import { providerAllowsPace } from "../../../lib/providerPace"; import { providerBarAccent } from "../../../components/providers/providerIcons"; import { @@ -147,6 +148,8 @@ export function ProviderDetailPane({ // after the selection changed) must not replace the current provider. const selectedProviderId = useRef(providerId); selectedProviderId.current = providerId; + // Keeps "Updated N ago" advancing while the pane stays open. + const nowMs = useNow(); const load = useCallback(async (id: string, signal?: { stale: boolean }) => { const isStale = () => signal?.stale === true || selectedProviderId.current !== id; @@ -352,7 +355,7 @@ export function ProviderDetailPane({ const hasFetchError = Boolean(detail.lastError?.trim()); const updatedMs = detail.lastUpdated ? Date.parse(detail.lastUpdated) : Number.NaN; const updatedText = Number.isFinite(updatedMs) - ? formatRelativeUpdated(updatedMs, t) + ? formatRelativeUpdated(updatedMs, t, nowMs) : busy ? t("SectionRefreshing") : hasFetchError