Skip to content
Open
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
7 changes: 5 additions & 2 deletions apps/desktop-tauri/src/components/QuotaBurndownChart.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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 (
<div className="quota-burndown-chart">
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop-tauri/src/components/card/CardHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CardHeader
name="Codex"
email={null}
plan={null}
error={null}
updatedAt="2026-10-11T11:59:30Z"
isRefreshing={false}
/>,
);
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(
<CardHeader name="Cursor" email={null} plan={null} error={null} updatedAt="" isRefreshing />,
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop-tauri/src/components/card/CardHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -105,6 +106,7 @@ export default function CardHeader({
isRefreshing: boolean;
}) {
const { t } = useLocale();
const nowMs = useNow();
return (
<header className="card-header">
<div className="card-header__line">
Expand All @@ -130,7 +132,7 @@ export default function CardHeader({
</>
) : (
<span className="card-header__subtitle">
{headerSubtitle(updatedAt, isRefreshing, t)}
{headerSubtitle(updatedAt, isRefreshing, t, nowMs)}
</span>
)}
{plan && <span className="card-header__plan">{plan}</span>}
Expand Down
45 changes: 45 additions & 0 deletions apps/desktop-tauri/src/hooks/useNow.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
18 changes: 18 additions & 0 deletions apps/desktop-tauri/src/hooks/useNow.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions apps/desktop-tauri/src/hooks/useTrayPanelController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down