authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-09-06 01:26:02+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 16:26:02-07:00
logf12d39359f0f76a64ff2d77959c5baf821df15be
tree4cc26db3b3e38c7ff9d3edb5c7f85fbd02b9fa6f
parentbe53bbd85044e937c359a89b6004c3d3c38ffcf1
signature Signed by PGP key B5690EEEBB952194

fix(ui): unify loading and refresh feedback across clients (#9561)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

42 files changed, 506 insertions(+), 183 deletions(-)

apps/mobile/src/features/usage/UsageRouteScreen.tsx+14-11
......@@ -11,7 +11,7 @@ import {
1111 formatUsd,
1212 makeWindow,
1313} from "@t3tools/shared/usageFormat";
14import { useMemo, useState } from "react";
14import { useMemo, useRef, useState } from "react";
1515import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
1616import { useSafeAreaInsets } from "react-native-safe-area-context";
1717
......@@ -91,10 +91,8 @@ export function UsageRouteScreen() {
9191 [isPast24Hours, merged.daily, merged.hourly],
9292 );
9393
94 // The pull spinner tracks re-scans of environments that have answered
95 // before. The initial scan renders its own placeholder, and an unreachable
96 // environment stays pending forever — neither may pin the spinner on.
97 const refreshingUsage = environments.some((entry) => entry.isPending && entry.summary !== null);
94 const [refreshingUsage, setRefreshingUsage] = useState(false);
95 const refreshingRef = useRef(false);
9896 const showingLimits = tab === "limits";
9997 const selectWindow = (days: number) => {
10098 setWindowSelection({
......@@ -103,17 +101,22 @@ export function UsageRouteScreen() {
103101 });
104102 };
105103 const refreshWindow = () => {
104 if (refreshingRef.current) return;
106105 const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
107106 if (
108 nextWindow.sinceDay === window.sinceDay &&
109 nextWindow.untilDay === window.untilDay &&
110 nextWindow.sinceTime === window.sinceTime &&
111 nextWindow.untilTime === window.untilTime
107 nextWindow.sinceDay !== window.sinceDay ||
108 nextWindow.untilDay !== window.untilDay ||
109 nextWindow.sinceTime !== window.sinceTime ||
110 nextWindow.untilTime !== window.untilTime
112111 ) {
113 refresh();
114 } else {
115112 setWindowSelection({ days: windowDays, window: nextWindow });
116113 }
114 refreshingRef.current = true;
115 setRefreshingUsage(true);
116 void refresh(nextWindow).finally(() => {
117 refreshingRef.current = false;
118 setRefreshingUsage(false);
119 });
117120 };
118121
119122 return (
apps/mobile/src/state/usage.ts+13-22
......@@ -16,7 +16,7 @@ import {
1616 type UsageSummary,
1717 type UsageSummaryInput,
1818} from "@t3tools/contracts";
19import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
19import { refreshUsage } from "@t3tools/client-runtime/state/usage";
2020import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
2121import * as Option from "effect/Option";
2222import { AsyncResult, Atom } from "effect/unstable/reactivity";
......@@ -72,7 +72,7 @@ export interface UsageView {
7272 * improve by waiting on them, so they must not read as "still reporting".
7373 */
7474 readonly isPartial: boolean;
75 readonly refresh: () => void;
75 readonly refresh: (input?: UsageSummaryInput) => Promise<void>;
7676}
7777
7878export function useUsage(input: UsageSummaryInput): UsageView {
......@@ -98,26 +98,17 @@ export function useUsage(input: UsageSummaryInput): UsageView {
9898 const atom = usageByWindowAtom(windowKey);
9999 const environments = useAtomValue(atom);
100100
101 // Refreshing only the derived atom would re-read the per-environment SWR
102 // queries within their stale window and change nothing. Refresh each
103 // environment's query so pull-to-refresh always rescans.
104 //
105 // Each environment refetches model pricing first, so a model released since
106 // its last daily fetch gets priced by the rescan. The rescan runs whether or
107 // not the refetch succeeds: an offline environment still recounts tokens.
108 const refresh = useCallback(() => {
109 const input = JSON.parse(windowKey) as UsageSummaryInput;
110 for (const environment of environments) {
111 const { environmentId } = environment;
112 const query = serverEnvironment.usageSummary({ environmentId, input });
113 void runAtomCommand(
114 appAtomRegistry,
115 serverEnvironment.refreshUsageRates,
116 { environmentId, input: {} },
117 { reportFailure: false },
118 ).finally(() => appAtomRegistry.refresh(query));
119 }
120 }, [environments, windowKey]);
101 const refresh = useCallback(
102 (nextInput?: UsageSummaryInput) =>
103 refreshUsage({
104 registry: appAtomRegistry,
105 server: serverEnvironment,
106 presentations: environmentPresentations,
107 environmentIds: environments.map(({ environmentId }) => environmentId),
108 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),
109 }),
110 [environments, windowKey],
111 );
121112
122113 const merged = useMemo(() => {
123114 const answered: EnvironmentUsage[] = environments.flatMap((environment) =>
apps/web/src/components/BranchToolbarBranchSelector.tsx+3-2
......@@ -1,3 +1,4 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
23import {
34 isAtomCommandInterrupted,
......@@ -5,7 +6,7 @@ import {
56} from "@t3tools/client-runtime/state/runtime";
67import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
78import { LegendList, type LegendListRef } from "@legendapp/list/react";
8import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
9import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react";
910import {
1011 useCallback,
1112 useDeferredValue,
......@@ -864,7 +865,7 @@ export function BranchToolbarBranchSelector({
864865 className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs"
865866 >
866867 <span className="flex min-w-0 items-center gap-1.5 font-medium text-muted-foreground">
867 <RefreshCwIcon aria-hidden="true" className="size-3 shrink-0 opacity-70" />
868 <RefreshIcon aria-hidden="true" className="size-3 shrink-0 opacity-70" />
868869 <span className="truncate">Start from origin</span>
869870 </span>
870871 <Switch
apps/web/src/components/DiffPanel.tsx+2-4
......@@ -1,3 +1,4 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import { useAtomValue } from "@effect/atom-react";
23import type { FileDiffContentsLoader } from "@pierre/diffs";
34import { useParams } from "@tanstack/react-router";
......@@ -17,7 +18,6 @@ import {
1718 Columns2Icon,
1819 FolderTreeIcon,
1920 PilcrowIcon,
20 RefreshCwIcon,
2121 Rows3Icon,
2222 SearchIcon,
2323 TextWrapIcon,
......@@ -766,9 +766,7 @@ export default function DiffPanel({
766766 />
767767 }
768768 >
769 <RefreshCwIcon
770 className={cn("size-3.5", branchDiffPreview.isPending && "animate-spin")}
771 />
769 <RefreshIcon className="size-3.5" refreshing={branchDiffPreview.isPending} />
772770 </TooltipTrigger>
773771 <TooltipPopup side="top">
774772 {branchDiffPreview.isPending ? "Refreshing diff…" : "Refresh diff"}
apps/web/src/components/LegacySidebar.tsx+2-2
......@@ -1,3 +1,4 @@
1import { Spinner } from "~/components/ui/spinner";
12import {
23 ArchiveIcon,
34 ArrowUpDownIcon,
......@@ -6,7 +7,6 @@ import {
67 ContainerIcon,
78 FolderPlusIcon,
89 Globe2Icon,
9 LoaderIcon,
1010 SearchIcon,
1111 SquarePenIcon,
1212 TerminalIcon,
......@@ -2648,7 +2648,7 @@ function LocalSecondaryStatus() {
26482648 variant="default"
26492649 className="rounded-2xl border-border/40 bg-accent/40 text-muted-foreground"
26502650 >
2651 <LoaderIcon className="animate-spin" />
2651 <Spinner />
26522652 <AlertTitle className="text-xs font-medium text-foreground">
26532653 Connecting {connecting.join(", ")}
26542654 </AlertTitle>
apps/web/src/components/chat/ChatComposer.tsx+4-4
......@@ -1,3 +1,4 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import type {
23 ApprovalRequestId,
34 AssistantCitation,
......@@ -789,7 +790,6 @@ import {
789790 LockIcon,
790791 LockOpenIcon,
791792 PenLineIcon,
792 RotateCcwIcon,
793793 SparklesIcon,
794794 XIcon,
795795} from "lucide-react";
......@@ -5273,7 +5273,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
52735273 />
52745274 }
52755275 >
5276 <RotateCcwIcon />
5276 <RefreshIcon />
52775277 </TooltipTrigger>
52785278 <TooltipPopup
52795279 side="top"
......@@ -5352,7 +5352,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
53525352 />
53535353 }
53545354 >
5355 <RotateCcwIcon />
5355 <RefreshIcon />
53565356 </TooltipTrigger>
53575357 <TooltipPopup
53585358 side="top"
......@@ -5425,7 +5425,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
54255425 />
54265426 }
54275427 >
5428 <RotateCcwIcon />
5428 <RefreshIcon />
54295429 </TooltipTrigger>
54305430 <TooltipPopup
54315431 side="top"
apps/web/src/components/chat/ComposerActivityStatus.tsx+4-4
......@@ -1,13 +1,13 @@
1import { LoaderCircleIcon } from "lucide-react";
2import { observeVisibleAnimation } from "../../lib/visibleAnimation";
1import { Spinner } from "~/components/ui/spinner";
2
33import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
44import { ComposerBanner } from "./ComposerBanner";
55
66export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase }) {
77 return (
88 <ComposerBanner.Row>
9 <ComposerBanner.Icon ref={observeVisibleAnimation}>
10 <LoaderCircleIcon className="motion-safe:visible-animate-spin" />
9 <ComposerBanner.Icon>
10 <Spinner />
1111 </ComposerBanner.Icon>
1212 <ComposerBanner.Content>
1313 <span
apps/web/src/components/chat/ComposerServerUpdateStatus.tsx+3-9
......@@ -1,8 +1,8 @@
1import { Spinner } from "~/components/ui/spinner";
12import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
2import { CircleAlertIcon, DownloadIcon, LoaderCircleIcon } from "lucide-react";
3import { CircleAlertIcon, DownloadIcon } from "lucide-react";
34import { useId, useState } from "react";
45
5import { observeVisibleAnimation } from "../../lib/visibleAnimation";
66import { serverUpdateStageLabel } from "../ServerUpdateAction";
77import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
88import { ComposerBanner } from "./ComposerBanner";
......@@ -13,13 +13,7 @@ export function ComposerServerUpdateIcon({
1313 readonly status: ServerUpdateState["status"];
1414}) {
1515 if (status === "running") {
16 return (
17 <LoaderCircleIcon
18 aria-hidden
19 ref={observeVisibleAnimation}
20 className="motion-safe:visible-animate-spin"
21 />
22 );
16 return <Spinner aria-hidden />;
2317 }
2418 if (status === "failed") {
2519 return <CircleAlertIcon aria-hidden className="text-error" />;
apps/web/src/components/clerk/ClerkUserProfilePage.tsx+3-2
......@@ -1,4 +1,5 @@
1import { RefreshCwIcon } from "lucide-react";
1import { RefreshIcon } from "~/components/ui/refresh-icon";
2
23import type { ReactNode } from "react";
34
45import { cn } from "../../lib/utils";
......@@ -55,7 +56,7 @@ export function ClerkUserProfileRefreshButton({
5556 disabled={disabled || isPending}
5657 onClick={onClick}
5758 >
58 <RefreshCwIcon aria-hidden="true" className={cn("size-3.5", isPending && "animate-spin")} />
59 <RefreshIcon aria-hidden="true" className="size-3.5" refreshing={isPending} />
5960 Refresh
6061 </Button>
6162 );
apps/web/src/components/files/FileBreadcrumbs.tsx+6-4
......@@ -1,5 +1,7 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { Spinner } from "~/components/ui/spinner";
13import type { EnvironmentId } from "@t3tools/contracts";
2import { ArrowLeftIcon, ChevronRightIcon, LoaderCircleIcon, RotateCwIcon } from "lucide-react";
4import { ArrowLeftIcon, ChevronRightIcon } from "lucide-react";
35import { useEffect, useMemo, useState } from "react";
46
57import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon";
......@@ -124,12 +126,12 @@ function BreadcrumbMenuContent(props: {
124126 <MenuGroup key={props.directoryPath}>
125127 {entriesQuery.isPending && entriesQuery.data === null ? (
126128 <MenuItem disabled>
127 <LoaderCircleIcon className="animate-spin" />
129 <Spinner />
128130 Loading folder…
129131 </MenuItem>
130132 ) : entriesQuery.error && entriesQuery.data === null ? (
131133 <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>
132 <RotateCwIcon />
134 <RefreshIcon refreshing={entriesQuery.isPending} />
133135 <span className="min-w-0 flex-1 truncate">Retry loading folder</span>
134136 </MenuItem>
135137 ) : !directoryAvailable && !entriesTruncated ? (
......@@ -177,7 +179,7 @@ function BreadcrumbMenuContent(props: {
177179 <>
178180 <MenuSeparator />
179181 <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>
180 <RotateCwIcon />
182 <RefreshIcon refreshing={entriesQuery.isPending} />
181183 Refresh failed — retry
182184 </MenuItem>
183185 </>
apps/web/src/components/files/FileBrowserPanel.tsx+3-3
......@@ -1,3 +1,4 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import type {
23 ContextMenuItem as TreeContextMenuItem,
34 ContextMenuOpenContext as TreeContextMenuOpenContext,
......@@ -5,7 +6,7 @@ import type {
56import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
67import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react";
78import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
8import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react";
9import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react";
910import { useEffect, useMemo, useRef } from "react";
1011
1112import { Button } from "~/components/ui/button";
......@@ -16,7 +17,6 @@ import { useComposerHandleContext } from "~/composerHandleContext";
1617import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
1718import { useTheme } from "~/hooks/useTheme";
1819import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
19import { cn } from "~/lib/utils";
2020import { readLocalApi } from "~/localApi";
2121import { T3_PIERRE_ICONS } from "~/pierre-icons";
2222import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";
......@@ -57,7 +57,7 @@ function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }
5757 />
5858 }
5959 >
60 <RotateCw className={cn(props.isPending && "animate-spin")} />
60 <RefreshIcon refreshing={props.isPending} />
6161 </TooltipTrigger>
6262 <TooltipPopup>{props.isPending ? "Refreshing…" : "Refresh files"}</TooltipPopup>
6363 </Tooltip>
apps/web/src/components/files/FilePreviewPanel.tsx+6-5
......@@ -1,3 +1,4 @@
1import { Spinner } from "~/components/ui/spinner";
12import type {
23 ChatFileAttachment,
34 EditorId,
......@@ -18,7 +19,7 @@ import {
1819 squashAtomCommandFailure,
1920} from "@t3tools/client-runtime/state/runtime";
2021import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
21import { Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
22import { Code2, Eye, FolderTree, Globe2 } from "lucide-react";
2223import * as Schema from "effect/Schema";
2324import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2425
......@@ -198,7 +199,7 @@ function WorkspaceImagePreview(props: {
198199 </div>
199200 ) : (
200201 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
201 <LoaderCircle className="size-5 animate-spin" />
202 <Spinner className="size-5" />
202203 </div>
203204 );
204205}
......@@ -252,7 +253,7 @@ function AttachmentBrowserPreview(props: {
252253 if (assetUrl._tag !== "Success") {
253254 return (
254255 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
255 <LoaderCircle className="size-5 animate-spin" />
256 <Spinner className="size-5" />
256257 </div>
257258 );
258259 }
......@@ -308,7 +309,7 @@ function WorkspaceBrowserPreview(props: {
308309 if (assetUrl._tag !== "Success") {
309310 return (
310311 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
311 <LoaderCircle className="size-5 animate-spin" />
312 <Spinner className="size-5" />
312313 </div>
313314 );
314315 }
......@@ -1262,7 +1263,7 @@ export default function FilePreviewPanel({
12621263 </div>
12631264 ) : relativePath && file.data === null ? (
12641265 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
1265 <LoaderCircle className="size-5 animate-spin" />
1266 <Spinner className="size-5" />
12661267 </div>
12671268 ) : relativePath && file.data ? (
12681269 isMarkdown && renderMarkdown ? (
apps/web/src/components/onboarding/FirstRunGate.tsx+2-2
......@@ -1,7 +1,7 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import { useAtomValue } from "@effect/atom-react";
23import { useLocation, useNavigate } from "@tanstack/react-router";
34import { Atom } from "effect/unstable/reactivity";
4import { RotateCcwIcon } from "lucide-react";
55import { useEffect, useLayoutEffect, useState } from "react";
66
77import {
......@@ -235,7 +235,7 @@ function FirstRunRecovery({
235235 }
236236 }}
237237 >
238 <RotateCcwIcon />
238 <RefreshIcon refreshing={retrying} />
239239 {settingsReadFailed ? "Retry" : "Reload"}
240240 </Button>
241241 </div>
apps/web/src/components/preview/PreviewChromeRow.tsx+2-2
......@@ -1,3 +1,4 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import {
23 ArrowLeft,
34 ArrowRight,
......@@ -5,7 +6,6 @@ import {
56 ExternalLink,
67 MousePointerClick,
78 PictureInPicture2,
8 RotateCw,
99} from "lucide-react";
1010import {
1111 type FormEvent,
......@@ -166,7 +166,7 @@ export function PreviewChromeRow({
166166 />
167167 }
168168 >
169 <RotateCw className={cn(loading && "animate-spin")} />
169 <RefreshIcon refreshing={loading} />
170170 </TooltipTrigger>
171171 <TooltipPopup>{loading ? "Loading…" : "Refresh"}</TooltipPopup>
172172 </Tooltip>
apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx+2-2
......@@ -1,4 +1,4 @@
1import { RefreshCwIcon } from "lucide-react";
1import { RefreshIcon } from "~/components/ui/refresh-icon";
22
33import { cn } from "~/lib/utils";
44
......@@ -23,7 +23,7 @@ export function PullRequestActivityUnavailableState({
2323 <p className="text-sm font-medium text-foreground">Could not load pull request activity</p>
2424 <p className="max-w-md text-xs text-muted-foreground">{error}</p>
2525 <Button size="sm" variant="outline" onClick={onRetry}>
26 <RefreshCwIcon aria-hidden className="size-3.5" />
26 <RefreshIcon aria-hidden className="size-3.5" />
2727 Retry
2828 </Button>
2929 </div>
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+19-6
......@@ -1,3 +1,4 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
23import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
34import {
......@@ -36,7 +37,6 @@ import {
3637 PanelRightIcon,
3738 PencilIcon,
3839 PlayIcon,
39 RefreshCwIcon,
4040 RotateCcwIcon,
4141 TriangleAlertIcon,
4242} from "lucide-react";
......@@ -728,10 +728,16 @@ export function PullRequestDetailPanel({
728728 // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run
729729 // and at worst answer from it.
730730 const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });
731 const [isInvalidating, setIsInvalidating] = useState(false);
731732 const refreshFromHost = useCallback(async () => {
732 await invalidate({ environmentId, input: { reference } });
733 refreshDetail();
734 setRefreshToken((token) => token + 1);
733 setIsInvalidating(true);
734 try {
735 await invalidate({ environmentId, input: { reference } });
736 refreshDetail();
737 setRefreshToken((token) => token + 1);
738 } finally {
739 setIsInvalidating(false);
740 }
735741 }, [environmentId, invalidate, reference, refreshDetail]);
736742 // A refresh asked for by the page: the detail, and through the token below, the diff with it.
737743 const appliedForcedToken = useRef(forcedRefreshToken);
......@@ -1670,8 +1676,14 @@ export function PullRequestDetailPanel({
16701676 <MoreHorizontalIcon className="size-4" />
16711677 </MenuTrigger>
16721678 <MenuPopup align="end" side="bottom" className="min-w-72">
1673 <MenuItem disabled={detailQuery.isPending} onClick={() => void refreshFromHost()}>
1674 <RefreshCwIcon className="size-3.5" />
1679 <MenuItem
1680 disabled={isInvalidating || detailQuery.isPending}
1681 onClick={() => void refreshFromHost()}
1682 >
1683 <RefreshIcon
1684 className="size-3.5"
1685 refreshing={isInvalidating || detailQuery.isPending}
1686 />
16751687 Refresh
16761688 </MenuItem>
16771689 <MenuItem disabled={handoff !== null} onClick={askAboutPullRequest}>
......@@ -2315,6 +2327,7 @@ export function PullRequestDetailPanel({
23152327 {detailQuery.error && !detail ? (
23162328 <PullRequestsUnavailableState
23172329 error={detailQuery.error}
2330 refreshing={detailQuery.isPending}
23182331 onRetry={refreshDetail}
23192332 {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})}
23202333 />
apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx+4-3
......@@ -1,3 +1,4 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12/**
23 * What the list shows when it has no rows to show.
34 *
......@@ -11,7 +12,7 @@
1112 * with no project to read from — leave the button out, since pressing it could only repeat what
1213 * is already happening or ask nobody.
1314 */
14import { PlusIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
15import { PlusIcon, SearchIcon } from "lucide-react";
1516
1617import { openCommandPalette } from "../../commandPaletteBus";
1718import { Button } from "../ui/button";
......@@ -149,7 +150,7 @@ export function PullRequestListEmptyState({
149150 {/* The hosts answered this query once; a pull request opened since then would answer
150151 differently, and nothing on screen says which of the two the reader is looking at. */}
151152 <Button size="sm" variant="outline" disabled={refreshing} onClick={onRefresh}>
152 <RefreshCwIcon className="size-3.5" />
153 <RefreshIcon className="size-3.5" refreshing={refreshing} />
153154 {refreshing ? "Checking..." : "Check again"}
154155 </Button>
155156 </EmptyContent>
......@@ -175,7 +176,7 @@ export function PullRequestListEmptyState({
175176 </Button>
176177 ) : null}
177178 <Button size="sm" variant="outline" disabled={refreshing} onClick={onRefresh}>
178 <RefreshCwIcon className="size-3.5" />
179 <RefreshIcon className="size-3.5" refreshing={refreshing} />
179180 {refreshing ? "Checking..." : "Check again"}
180181 </Button>
181182 </EmptyContent>
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+2-2
......@@ -1,3 +1,4 @@
1import { Spinner } from "~/components/ui/spinner";
12import type {
23 EnvironmentId,
34 ProjectId,
......@@ -17,7 +18,6 @@ import {
1718 GitPullRequestDraftIcon,
1819 LayersIcon,
1920 ListFilterIcon,
20 LoaderIcon,
2121 SearchIcon,
2222 TagIcon,
2323 UserRoundIcon,
......@@ -120,7 +120,7 @@ export function PullRequestSearchInput({
120120 return (
121121 <InputGroup className="min-w-0 flex-1 **:[input]:h-9 sm:**:[input]:h-8">
122122 <InputGroupAddon>
123 {busy ? <LoaderIcon aria-hidden className="animate-spin" /> : <SearchIcon aria-hidden />}
123 {busy ? <Spinner aria-hidden /> : <SearchIcon aria-hidden />}
124124 </InputGroupAddon>
125125 <InputGroupInput
126126 type="search"
apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx+12-3
......@@ -1,4 +1,5 @@
1import { ExternalLinkIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { ExternalLinkIcon, GitPullRequestIcon } from "lucide-react";
23
34import { Button } from "../ui/button";
45import {
......@@ -14,11 +15,13 @@ export function PullRequestsUnavailableState({
1415 title = "Could not load pull requests",
1516 error,
1617 onRetry,
18 refreshing = false,
1719 gitHubUrl,
1820}: {
1921 title?: string;
2022 error: string;
2123 onRetry?: () => void;
24 refreshing?: boolean;
2225 gitHubUrl?: string;
2326}) {
2427 return (
......@@ -35,8 +38,14 @@ export function PullRequestsUnavailableState({
3538 {onRetry || gitHubUrl ? (
3639 <EmptyContent className="flex-row flex-wrap justify-center gap-2">
3740 {onRetry ? (
38 <Button size="sm" variant="outline" onClick={onRetry}>
39 <RefreshCwIcon className="size-3.5" />
41 <Button
42 size="sm"
43 variant="outline"
44 onClick={onRetry}
45 disabled={refreshing}
46 aria-busy={refreshing}
47 >
48 <RefreshIcon className="size-3.5" refreshing={refreshing} />
4049 Retry
4150 </Button>
4251 ) : null}
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+3-3
......@@ -1,3 +1,4 @@
1import { Spinner } from "~/components/ui/spinner";
12import type {
23 PullRequestActor,
34 PullRequestCheck,
......@@ -15,7 +16,6 @@ import {
1516 GitPullRequestClosedIcon,
1617 GitPullRequestDraftIcon,
1718 GitPullRequestIcon,
18 LoaderIcon,
1919 TriangleAlertIcon,
2020} from "lucide-react";
2121import { Children, isValidElement, type ReactNode } from "react";
......@@ -119,7 +119,7 @@ export function PullRequestStateGlyph({
119119}
120120
121121const CHECK_STATUS_PRESENTATION = {
122 pending: { label: "Running", Icon: LoaderIcon, toneClassName: "animate-spin text-amber-500" },
122 pending: { label: "Running", Icon: Spinner, toneClassName: "text-amber-500" },
123123 "action-required": {
124124 label: "Awaiting action",
125125 Icon: CircleDotIcon,
......@@ -136,7 +136,7 @@ const CHECK_STATUS_PRESENTATION = {
136136 neutral: { label: "Neutral", Icon: CircleDashedIcon, toneClassName: "text-muted-foreground/70" },
137137} as const satisfies Record<
138138 PullRequestCheckStatus,
139 { label: string; Icon: typeof CircleCheckIcon; toneClassName: string }
139 { label: string; Icon: typeof CircleCheckIcon | typeof Spinner; toneClassName: string }
140140>;
141141
142142function isWorkflowApprovalCheck(check: Pick<PullRequestCheck, "status" | "url">): boolean {
apps/web/src/components/search/ProjectContentSearchDialog.tsx+3-2
......@@ -1,5 +1,6 @@
1import { Spinner } from "~/components/ui/spinner";
12import type { ProjectContentMatch } from "@t3tools/contracts";
2import { LoaderCircle } from "lucide-react";
3
34import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
45
56import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget";
......@@ -225,7 +226,7 @@ function OpenContentSearchDialog(props: {
225226 <div className="flex h-9 shrink-0 items-center border-b px-3 text-xs text-muted-foreground">
226227 {search.isPending ? (
227228 <span className="flex items-center gap-2">
228 <LoaderCircle className="size-3.5 animate-spin" /> Searching…
229 <Spinner className="size-3.5" /> Searching…
229230 </span>
230231 ) : search.error ? (
231232 <span className="text-destructive">{search.error}</span>
apps/web/src/components/settings/DiagnosticsSettings.tsx+2-2
......@@ -1,3 +1,4 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import {
23 AlertTriangleIcon,
34 ChevronDownIcon,
......@@ -5,7 +6,6 @@ import {
56 CopyIcon,
67 FolderOpenIcon,
78 InfoIcon,
8 RefreshCwIcon,
99} from "lucide-react";
1010import { useAtomValue } from "@effect/atom-react";
1111import {
......@@ -766,7 +766,7 @@ function DiagnosticsRefreshButton({
766766 onClick={onClick}
767767 aria-label={label}
768768 >
769 <RefreshCwIcon className={cn(isPending && "animate-spin")} />
769 <RefreshIcon refreshing={isPending} />
770770 </Button>
771771 }
772772 />
apps/web/src/components/settings/ProviderInstanceCard.tsx+3-2
......@@ -1,10 +1,11 @@
11"use client";
22
3import { Spinner } from "~/components/ui/spinner";
4
35import {
46 ArrowUpCircleIcon,
57 CopyIcon,
68 DownloadIcon,
7 LoaderIcon,
89 LockIcon,
910 LockOpenIcon,
1011 PlusIcon,
......@@ -739,7 +740,7 @@ export function ProviderInstanceCard({
739740 disabled={isUpdating}
740741 onClick={onRunUpdate}
741742 >
742 {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
743 {isUpdating ? <Spinner /> : <DownloadIcon />}
743744 {isUpdating ? "Updating" : "Update now"}
744745 </Button>
745746 ) : null}
apps/web/src/components/settings/ProviderSettingsPanel.tsx+3-2
......@@ -1,3 +1,4 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import { useAtomValue } from "@effect/atom-react";
23import { connectionStatusTitle } from "@t3tools/client-runtime/connection";
34import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
......@@ -24,7 +25,7 @@ import * as Arr from "effect/Array";
2425import * as Duration from "effect/Duration";
2526import * as Equal from "effect/Equal";
2627import * as Result from "effect/Result";
27import { PlusIcon, RefreshCwIcon } from "lucide-react";
28import { PlusIcon } from "lucide-react";
2829import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2930
3031import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
......@@ -993,7 +994,7 @@ export function EnvironmentProviderSettings({
993994 aria-busy={isRefreshingProviders}
994995 onClick={() => void refreshProviders()}
995996 >
996 <RefreshCwIcon />
997 <RefreshIcon refreshing={isRefreshingProviders} />
997998 <span className="sr-only">Refresh provider status</span>
998999 <span className="hidden min-w-0 truncate sm:inline">
9991000 {isRefreshingProviders ? (
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+4-7
......@@ -1,3 +1,4 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import {
23 ActivityIcon,
34 AlertTriangleIcon,
......@@ -9,8 +10,6 @@ import {
910 GaugeIcon,
1011 HardDriveIcon,
1112 MemoryStickIcon,
12 RefreshCwIcon,
13 RotateCcwIcon,
1413} from "lucide-react";
1514import type {
1615 BackgroundBooleanState,
......@@ -982,9 +981,7 @@ export function ResourceTelemetryDiagnostics() {
982981 onClick={telemetry.refresh}
983982 aria-label="Refresh resource telemetry"
984983 >
985 <RefreshCwIcon
986 className={cn("size-3", telemetry.isPending && "animate-spin")}
987 />
984 <RefreshIcon className="size-3" refreshing={telemetry.isPending} />
988985 </Button>
989986 }
990987 />
......@@ -1095,7 +1092,7 @@ export function ResourceTelemetryDiagnostics() {
10951092 headerAction={
10961093 collectorNeedsRetry ? (
10971094 <Button size="xs" variant="outline" disabled={isRetrying} onClick={retryCollector}>
1098 <RotateCcwIcon className={cn("size-3", isRetrying && "animate-spin")} />
1095 <RefreshIcon className="size-3" refreshing={isRetrying} />
10991096 Retry monitor
11001097 </Button>
11011098 ) : null
......@@ -1233,7 +1230,7 @@ export function ResourceTelemetryDiagnostics() {
12331230 onClick={history.refresh}
12341231 aria-label="Refresh resource history"
12351232 >
1236 <RefreshCwIcon className={cn("size-3", history.isPending && "animate-spin")} />
1233 <RefreshIcon className="size-3" refreshing={history.isPending} />
12371234 </Button>
12381235 </div>
12391236 }
apps/web/src/components/settings/SettingsPanels.tsx+3-2
......@@ -1,4 +1,5 @@
1import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react";
1import { Spinner } from "~/components/ui/spinner";
2import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react";
23import { Link, useNavigate } from "@tanstack/react-router";
34import type { CSSProperties, ReactNode } from "react";
45import { useCallback, useEffect, useMemo, useRef, useState } from "react";
......@@ -3029,7 +3030,7 @@ export function ArchivedThreadsPanel() {
30293030 title={
30303031 <span className="inline-flex items-center gap-2">
30313032 {isLoadingArchive ? (
3032 <LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
3033 <Spinner className="size-3.5 text-muted-foreground" />
30333034 ) : (
30343035 <ArchiveIcon className="size-3.5 text-muted-foreground" />
30353036 )}
apps/web/src/components/settings/SourceControlSettings.tsx+4-3
......@@ -1,4 +1,5 @@
1import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { ChevronDownIcon, GitPullRequestIcon } from "lucide-react";
23import * as Duration from "effect/Duration";
34import * as Option from "effect/Option";
45import { useEffect, useState, type ReactNode } from "react";
......@@ -487,7 +488,7 @@ function EmptySourceControlDiscovery({
487488 </EmptyHeader>
488489 <EmptyContent>
489490 <Button size="sm" variant="outline" onClick={onScan} disabled={isPending}>
490 <RefreshCwIcon className={cn("size-3.5", isPending && "animate-spin")} />
491 <RefreshIcon className="size-3.5" refreshing={isPending} />
491492 Scan
492493 </Button>
493494 </EmptyContent>
......@@ -532,7 +533,7 @@ export function SourceControlSettingsPanel() {
532533 disabled={discovery.isPending}
533534 aria-label="Rescan server environment"
534535 >
535 <RefreshCwIcon className={cn(discovery.isPending && "animate-spin")} />
536 <RefreshIcon refreshing={discovery.isPending} />
536537 </Button>
537538 }
538539 />
apps/web/src/components/settings/ThemeSearchSection.tsx+3-8
......@@ -1,10 +1,5 @@
1import {
2 ExternalLinkIcon,
3 PackagePlusIcon,
4 PaletteIcon,
5 RefreshCwIcon,
6 SearchIcon,
7} from "lucide-react";
1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, SearchIcon } from "lucide-react";
83import { useCallback, useEffect, useRef, useState } from "react";
94import {
105 importOpenVsxThemeExtension,
......@@ -417,7 +412,7 @@ export function ThemeSearchSection({
417412 {isInstalling ? (
418413 <Spinner />
419414 ) : isInstalled ? (
420 <RefreshCwIcon />
415 <RefreshIcon />
421416 ) : (
422417 <PackagePlusIcon />
423418 )}
apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx+5-5
......@@ -1,8 +1,7 @@
1import { CheckIcon, DownloadIcon, RefreshCwIcon, RotateCwIcon } from "lucide-react";
1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react";
23import type { AnimationEventHandler } from "react";
34
4import { cn } from "../../lib/utils";
5
65const DOWNLOAD_PROGRESS_RADIUS = 14;
76const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS;
87
......@@ -118,8 +117,9 @@ export function DesktopUpdateStatusIcon({
118117 if (status === "downloaded") return <DesktopUpdateDownloadedIcon />;
119118
120119 return (
121 <RefreshCwIcon
122 className={cn("size-4", status === "checking" && isCheckAnimating && "animate-spin")}
120 <RefreshIcon
121 className="size-4"
122 refreshing={status === "checking" && isCheckAnimating === true}
123123 onAnimationIteration={onCheckAnimationIteration}
124124 />
125125 );
apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx+3-2
......@@ -1,7 +1,8 @@
1import { Spinner } from "~/components/ui/spinner";
12import { useNavigate } from "@tanstack/react-router";
23import { useAtomValue } from "@effect/atom-react";
34import type { ServerProvider } from "@t3tools/contracts";
4import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react";
5import { CircleCheckIcon, DownloadIcon, TriangleAlertIcon, XIcon } from "lucide-react";
56import { useCallback, useEffect, useState, type CSSProperties } from "react";
67
78import { primaryServerProvidersAtom } from "../../state/server";
......@@ -173,7 +174,7 @@ export function SidebarProviderUpdatePill() {
173174 onClick={openProviderSettings}
174175 >
175176 {displayedView.tone === "loading" ? (
176 <LoaderIcon className="size-3.5 animate-spin" />
177 <Spinner className="size-3.5" />
177178 ) : displayedView.tone === "success" ? (
178179 <CircleCheckIcon className="size-3.5" />
179180 ) : displayedView.tone === "error" ? (
apps/web/src/components/ui/refresh-icon.tsx created+20
......@@ -0,0 +1,20 @@
1import { RefreshCwIcon } from "lucide-react";
2
3import { cn } from "~/lib/utils";
4import { observeVisibleAnimation } from "~/lib/visibleAnimation";
5
6/** Keep the refresh glyph in place while its owning action is running. */
7export function RefreshIcon({
8 refreshing = false,
9 className,
10 ...props
11}: React.ComponentPropsWithoutRef<typeof RefreshCwIcon> & { refreshing?: boolean }) {
12 return (
13 <RefreshCwIcon
14 aria-hidden
15 ref={refreshing ? observeVisibleAnimation : undefined}
16 className={cn(refreshing && "motion-safe:visible-animate-spin", className)}
17 {...props}
18 />
19 );
20}
apps/web/src/components/ui/spinner.tsx+6-4
......@@ -1,11 +1,13 @@
1import { Loader2Icon } from "lucide-react";
1import { LoaderCircleIcon } from "lucide-react";
2import { observeVisibleAnimation } from "~/lib/visibleAnimation";
23import { cn } from "~/lib/utils";
34
4function Spinner({ className, ...props }: React.ComponentProps<typeof Loader2Icon>) {
5function Spinner({ className, ...props }: React.ComponentPropsWithoutRef<typeof LoaderCircleIcon>) {
56 return (
6 <Loader2Icon
7 <LoaderCircleIcon
78 aria-label="Loading"
8 className={cn("animate-spin", className)}
9 ref={observeVisibleAnimation}
10 className={cn("motion-safe:visible-animate-spin", className)}
911 role="status"
1012 {...props}
1113 />
apps/web/src/components/ui/toast.tsx+4-3
......@@ -1,5 +1,7 @@
11"use client";
22
3import { Spinner } from "~/components/ui/spinner";
4
35import { Toast } from "@base-ui/react/toast";
46import {
57 useEffect,
......@@ -20,7 +22,6 @@ import {
2022 CircleCheckIcon,
2123 CopyIcon,
2224 InfoIcon,
23 LoaderCircleIcon,
2425 TriangleAlertIcon,
2526 XIcon,
2627} from "lucide-react";
......@@ -83,7 +84,7 @@ const threadToastVisibleTimeoutRemainingMs = new Map<ToastId, number>();
8384const TOAST_ICONS = {
8485 error: CircleAlertIcon,
8586 info: InfoIcon,
86 loading: LoaderCircleIcon,
87 loading: Spinner,
8788 success: CircleCheckIcon,
8889 warning: TriangleAlertIcon,
8990} as const;
......@@ -357,7 +358,7 @@ function ToastBodyContent({
357358 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
358359 data-slot="toast-icon"
359360 >
360 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
361 <Icon className="in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
361362 </div>
362363 ) : null}
363364 <div
apps/web/src/components/usage/UsagePage.tsx+35-16
......@@ -1,3 +1,4 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import { useAtomValue } from "@effect/atom-react";
23import {
34 USAGE_CONTRACT_VERSION,
......@@ -8,10 +9,9 @@ import {
89 CircleAlertIcon,
910 ChevronDownIcon,
1011 CircleDashedIcon,
11 RefreshCwIcon,
1212 SlidersHorizontalIcon,
1313} from "lucide-react";
14import { useMemo, useState } from "react";
14import { useMemo, useRef, useState } from "react";
1515
1616import {
1717 isCompatibleUsageContractVersion,
......@@ -88,6 +88,8 @@ export function UsagePage() {
8888 }));
8989 const [metric, setMetric] = useState<UsageMetric>("cost");
9090 const showingLimits = metric === "limits";
91 const [isRefreshing, setIsRefreshing] = useState(false);
92 const refreshingRef = useRef(false);
9193 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
9294 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
9395 useState<ReadonlySet<EnvironmentId> | null>(null);
......@@ -138,26 +140,39 @@ export function UsagePage() {
138140 });
139141 };
140142 const refreshWindow = () => {
143 if (refreshingRef.current) return;
144
141145 if (showingLimits) {
142 for (const [environmentId, presentation] of presentations) {
143 if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) continue;
144 if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) {
145 void refreshProviders({ environmentId, input: {} });
146 }
147 }
146 refreshingRef.current = true;
147 setIsRefreshing(true);
148 void Promise.all(
149 Array.from(presentations, ([environmentId, presentation]) => {
150 if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return;
151 if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) {
152 return refreshProviders({ environmentId, input: {} });
153 }
154 }),
155 ).finally(() => {
156 refreshingRef.current = false;
157 setIsRefreshing(false);
158 });
148159 return;
149160 }
150161 const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
151162 if (
152 nextWindow.sinceDay === window.sinceDay &&
153 nextWindow.untilDay === window.untilDay &&
154 nextWindow.sinceTime === window.sinceTime &&
155 nextWindow.untilTime === window.untilTime
163 nextWindow.sinceDay !== window.sinceDay ||
164 nextWindow.untilDay !== window.untilDay ||
165 nextWindow.sinceTime !== window.sinceTime ||
166 nextWindow.untilTime !== window.untilTime
156167 ) {
157 refresh();
158 } else {
159168 setWindowSelection({ days: windowDays, window: nextWindow });
160169 }
170 refreshingRef.current = true;
171 setIsRefreshing(true);
172 void refresh(nextWindow).finally(() => {
173 refreshingRef.current = false;
174 setIsRefreshing(false);
175 });
161176 };
162177 const windowLabel =
163178 isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined
......@@ -225,10 +240,12 @@ export function UsagePage() {
225240 <Button
226241 onClick={refreshWindow}
227242 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}
243 aria-busy={isRefreshing}
244 disabled={isRefreshing}
228245 size="icon-sm"
229246 variant="ghost"
230247 >
231 <RefreshCwIcon className="size-3.5" />
248 <RefreshIcon className="size-3.5" refreshing={isRefreshing} />
232249 </Button>
233250 </div>
234251 <div className="col-span-2 ms-auto flex min-w-0 items-center justify-end gap-1 xl:hidden">
......@@ -282,10 +299,12 @@ export function UsagePage() {
282299 <Button
283300 onClick={refreshWindow}
284301 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}
302 aria-busy={isRefreshing}
303 disabled={isRefreshing}
285304 size="icon-sm"
286305 variant="ghost"
287306 >
288 <RefreshCwIcon className="size-3.5" />
307 <RefreshIcon className="size-3.5" refreshing={isRefreshing} />
289308 </Button>
290309 </div>
291310 </div>
apps/web/src/routes/_chat.index.tsx+3-2
......@@ -1,6 +1,7 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
12import { scopeProjectRef } from "@t3tools/client-runtime/environment";
23import { createFileRoute, Link } from "@tanstack/react-router";
3import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react";
4import { LinkIcon, PlusIcon } from "lucide-react";
45import { useCallback, useEffect, useMemo, useRef, useState } from "react";
56
67import { openCommandPalette } from "../commandPaletteBus";
......@@ -96,7 +97,7 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {
9697 </EmptyDescription>
9798 <div className="mt-5 flex justify-center">
9899 <Button size="sm" onClick={onRetry}>
99 <RotateCcwIcon className="size-4" />
100 <RefreshIcon className="size-4" />
100101 Try again
101102 </Button>
102103 </div>
apps/web/src/routes/_chat.pull-requests.tsx+9-5
......@@ -1,3 +1,5 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { Spinner } from "~/components/ui/spinner";
13import { scopeThreadRef } from "@t3tools/client-runtime/environment";
24import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts";
35import type {
......@@ -26,10 +28,8 @@ import {
2628 LayersIcon,
2729 ListChecksIcon,
2830 PenLineIcon,
29 LoaderIcon,
3031 Maximize2Icon,
3132 Minimize2Icon,
32 RefreshCwIcon,
3333 SearchIcon,
3434} from "lucide-react";
3535import {
......@@ -1581,7 +1581,11 @@ function PullRequestsRouteView() {
15811581 ) : firstLoad ? (
15821582 <PullRequestListGhost rows={7} />
15831583 ) : listQuery.error && entries.length === 0 ? (
1584 <PullRequestsUnavailableState error={listQuery.error} onRetry={() => listQuery.refresh()} />
1584 <PullRequestsUnavailableState
1585 error={listQuery.error}
1586 refreshing={listQuery.isPending}
1587 onRetry={() => listQuery.refresh()}
1588 />
15851589 ) : carriedToNothing ? (
15861590 <PullRequestListGhost rows={7} />
15871591 ) : entries.length === 0 ? (
......@@ -1658,7 +1662,7 @@ function PullRequestsRouteView() {
16581662 <div className="flex justify-center py-3 text-xs text-muted-foreground">
16591663 {loadingMore ? (
16601664 <span className="flex items-center gap-2">
1661 <LoaderIcon aria-hidden className="size-3.5 animate-spin" />
1665 <Spinner aria-hidden className="size-3.5" />
16621666 {sentCursors === null ? "Updating pull requests" : "Loading more"}
16631667 </span>
16641668 ) : canContinue || pageSize < MAX_PAGE_SIZE ? (
......@@ -2318,7 +2322,7 @@ function PullRequestRefreshControl({
23182322 onClick={onRefresh}
23192323 disabled={refreshing}
23202324 >
2321 <RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} />
2325 <RefreshIcon className="size-4" refreshing={refreshing} />
23222326 </Button>
23232327 );
23242328}
apps/web/src/state/usage.ts+13-22
......@@ -13,7 +13,7 @@ import {
1313 type UsageSummary,
1414 type UsageSummaryInput,
1515} from "@t3tools/contracts";
16import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
16import { refreshUsage } from "@t3tools/client-runtime/state/usage";
1717import * as Option from "effect/Option";
1818import { AsyncResult, Atom } from "effect/unstable/reactivity";
1919import { useCallback, useMemo } from "react";
......@@ -70,7 +70,7 @@ export interface UsageView {
7070 * improve by waiting on them, so they must not read as "still reporting".
7171 */
7272 readonly isPartial: boolean;
73 readonly refresh: () => void;
73 readonly refresh: (input?: UsageSummaryInput) => Promise<void>;
7474}
7575
7676export function useUsage(
......@@ -108,26 +108,17 @@ export function useUsage(
108108 [environments, selectedEnvironmentIds],
109109 );
110110
111 // Refreshing only the derived atom would re-read the per-environment SWR
112 // queries within their stale window and change nothing. Refresh each
113 // environment's query so the button always rescans.
114 //
115 // Each environment refetches model pricing first, so a model released since
116 // its last daily fetch gets priced by the rescan. The rescan runs whether or
117 // not the refetch succeeds: an offline environment still recounts tokens.
118 const refresh = useCallback(() => {
119 const input = JSON.parse(windowKey) as UsageSummaryInput;
120 for (const environment of selectedEnvironments) {
121 const { environmentId } = environment;
122 const query = serverEnvironment.usageSummary({ environmentId, input });
123 void runAtomCommand(
124 appAtomRegistry,
125 serverEnvironment.refreshUsageRates,
126 { environmentId, input: {} },
127 { reportFailure: false },
128 ).finally(() => appAtomRegistry.refresh(query));
129 }
130 }, [selectedEnvironments, windowKey]);
111 const refresh = useCallback(
112 (nextInput?: UsageSummaryInput) =>
113 refreshUsage({
114 registry: appAtomRegistry,
115 server: serverEnvironment,
116 presentations: environmentPresentations,
117 environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId),
118 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),
119 }),
120 [selectedEnvironments, windowKey],
121 );
131122
132123 const merged = useMemo(() => {
133124 const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) =>
packages/client-runtime/package.json+4
......@@ -147,6 +147,10 @@
147147 "types": "./src/state/runtime.ts",
148148 "default": "./src/state/runtime.ts"
149149 },
150 "./state/usage": {
151 "types": "./src/state/usage.ts",
152 "default": "./src/state/usage.ts"
153 },
150154 "./state/server": {
151155 "types": "./src/state/server.ts",
152156 "default": "./src/state/server.ts"
packages/client-runtime/src/state/runtime.test.ts+18
......@@ -683,6 +683,24 @@ describe("executeAtomQuery", () => {
683683
684684 registry.dispose();
685685 });
686
687 it("settles when its caller aborts a waiting query", async () => {
688 const registry = AtomRegistry.make();
689 const controller = new AbortController();
690 const resultPromise = executeAtomQuery(registry, Atom.make(Effect.never), {
691 reportDefect: false,
692 signal: controller.signal,
693 });
694
695 controller.abort();
696
697 const result = await resultPromise;
698 expect(result._tag).toBe("Failure");
699 if (result._tag === "Failure") {
700 expect(Cause.hasInterruptsOnly(result.cause)).toBe(true);
701 }
702 registry.dispose();
703 });
686704});
687705
688706describe("runtime command runner", () => {
packages/client-runtime/src/state/runtime.ts+7-1
......@@ -336,6 +336,8 @@ export interface AtomQueryOptions extends AtomCommandOptions {
336336 * verification flows where a cached failure must not satisfy a retry.
337337 */
338338 readonly refresh?: boolean;
339 /** Interrupt the query wait when its caller no longer wants the result. */
340 readonly signal?: AbortSignal;
339341}
340342
341343export async function executeAtomQuery<A, E>(
......@@ -362,7 +364,11 @@ export async function executeAtomQuery<A, E>(
362364 });
363365 }),
364366 );
365 return executeAtomCommand(() => Effect.runPromiseExit(query), options, reporter);
367 return executeAtomCommand(
368 () => Effect.runPromiseExit(query, { signal: options.signal }),
369 options,
370 reporter,
371 );
366372}
367373
368374export function createRuntimeCommand<R, ER, W, A, E>(
packages/client-runtime/src/state/usage.test.ts created+184
......@@ -0,0 +1,184 @@
1import {
2 EnvironmentId,
3 UsageDay,
4 USAGE_CONTRACT_VERSION,
5 type UsageSummary,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
9import { afterEach, describe, expect, it } from "vite-plus/test";
10
11import type { EnvironmentPresentation } from "../connection/presentation.ts";
12import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";
13import { refreshUsage } from "./usage.ts";
14
15const input = {
16 sinceDay: UsageDay.make("2026-09-05"),
17 untilDay: UsageDay.make("2026-09-05"),
18 timeZone: "UTC",
19};
20const pricing = { status: "fresh" as const, source: "test", fetchedAt: null, knownModels: 1 };
21const summary: UsageSummary = {
22 ...input,
23 contractVersion: USAGE_CONTRACT_VERSION,
24 readAt: "2026-09-05T12:00:00Z",
25 buckets: [],
26 sources: [],
27 pricing,
28 scanDurationMs: 1,
29};
30const registries: AtomRegistry.AtomRegistry[] = [];
31afterEach(() => {
32 for (const registry of registries.splice(0)) registry.dispose();
33});
34
35function harness(ids = ["a"]) {
36 const registry = AtomRegistry.make();
37 registries.push(registry);
38 const environments = ids.map((id) => {
39 const environmentId = EnvironmentId.make(id);
40 const rates = Promise.withResolvers<
41 AsyncResult.Success<typeof pricing> | AsyncResult.Failure<never, unknown>
42 >();
43 const scan = Promise.withResolvers<UsageSummary>();
44 const scanStarted = Promise.withResolvers<void>();
45 const presentation = Atom.make({
46 connection: { phase: "connected" },
47 } as EnvironmentPresentation | null);
48 const query = Atom.make(
49 Effect.promise(() => {
50 scanStarted.resolve();
51 return scan.promise;
52 }),
53 );
54 return { environmentId, rates, scan, scanStarted, presentation, query };
55 });
56 function get(environmentId: EnvironmentId) {
57 const environment = environments.find((entry) => entry.environmentId === environmentId);
58 if (!environment) throw new Error(`Unknown environment: ${environmentId}`);
59 return environment;
60 }
61 const options = {
62 registry,
63 environmentIds: environments.map((entry) => entry.environmentId),
64 input,
65 server: {
66 usageSummary: ({ environmentId }: { environmentId: EnvironmentId }) =>
67 get(environmentId).query,
68 refreshUsageRates: {
69 label: "test:rates",
70 run: (
71 _registry: AtomRegistry.AtomRegistry,
72 { environmentId }: { environmentId: EnvironmentId },
73 ) => get(environmentId).rates.promise,
74 },
75 },
76 presentations: {
77 presentationAtom: (environmentId: EnvironmentId) => get(environmentId).presentation,
78 },
79 } satisfies Parameters<typeof refreshUsage>[0];
80 return { registry, environments, refresh: () => refreshUsage(options) };
81}
82
83describe("manual usage refresh", () => {
84 it.each(["success", "failure"])("waits for the rescan after a pricing %s", async (result) => {
85 const {
86 environments: [environment],
87 refresh,
88 } = harness();
89 const entry = environment!;
90 let finished = false;
91 const refreshing = refresh().then(() => {
92 finished = true;
93 });
94 expect(finished).toBe(false);
95 entry.rates.resolve(
96 result === "success"
97 ? AsyncResult.success(pricing)
98 : AsyncResult.fail(new Error("Pricing offline")),
99 );
100 await entry.scanStarted.promise;
101 expect(finished).toBe(false);
102 entry.scan.resolve(summary);
103 await refreshing;
104 expect(finished).toBe(true);
105 });
106
107 it("settles when an environment disconnects during the rescan", async () => {
108 const {
109 registry,
110 environments: [environment],
111 refresh,
112 } = harness();
113 const entry = environment!;
114 const refreshing = refresh();
115 entry.rates.resolve(AsyncResult.success(pricing));
116 await entry.scanStarted.promise;
117 registry.set(entry.presentation, null);
118 await refreshing;
119 });
120
121 it("waits for healthy environments without waiting for a recovering environment", async () => {
122 const { registry, environments, refresh } = harness(["healthy", "recovering"]);
123 const [healthy, recovering] = environments;
124 registry.set(recovering!.presentation, null);
125 let finished = false;
126 const refreshing = refresh().then(() => {
127 finished = true;
128 });
129 for (const entry of environments) entry.rates.resolve(AsyncResult.success(pricing));
130 await healthy!.scanStarted.promise;
131 expect(finished).toBe(false);
132 healthy!.scan.resolve(summary);
133 await refreshing;
134 expect(finished).toBe(true);
135 });
136
137 it("settles when connected state has no usable RPC session", async () => {
138 const {
139 environments: [environment],
140 refresh,
141 } = harness();
142 const entry = environment!;
143 const refreshing = refresh();
144 entry.rates.resolve(
145 AsyncResult.fail(
146 new EnvironmentRpcUnavailableError({
147 environmentId: entry.environmentId,
148 message: "No session",
149 }),
150 ),
151 );
152 await refreshing;
153 });
154
155 it("replaces a scan that started before pricing was refreshed", async () => {
156 const {
157 registry,
158 environments: [environment],
159 refresh,
160 } = harness();
161 const entry = environment!;
162 let reads = 0;
163 const rescanned = Promise.withResolvers<void>();
164 const query = Atom.make(
165 Effect.promise(() => {
166 reads += 1;
167 if (reads > 1) {
168 rescanned.resolve();
169 return Promise.resolve(summary);
170 }
171 return new Promise<UsageSummary>(() => {});
172 }),
173 );
174 entry.query = query;
175 const unmount = registry.mount(query);
176 expect(reads).toBe(1);
177 const refreshing = refresh();
178 entry.rates.resolve(AsyncResult.success(pricing));
179 await rescanned.promise;
180 await refreshing;
181 expect(reads).toBe(2);
182 unmount();
183 });
184});
packages/client-runtime/src/state/usage.ts created+61
......@@ -0,0 +1,61 @@
1import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import type { AtomRegistry } from "effect/unstable/reactivity";
4
5import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";
6import type { createEnvironmentPresentationAtoms } from "./presentation.ts";
7import { executeAtomQuery, runAtomCommand, squashAtomCommandFailure } from "./runtime.ts";
8import type { createServerEnvironmentAtoms } from "./server.ts";
9
10const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError);
11
12/** Refresh pricing, then await each selected environment's rescan while it remains connected. */
13export async function refreshUsage({
14 registry,
15 server,
16 presentations,
17 environmentIds,
18 input,
19}: {
20 registry: AtomRegistry.AtomRegistry;
21 server: Pick<
22 ReturnType<typeof createServerEnvironmentAtoms>,
23 "usageSummary" | "refreshUsageRates"
24 >;
25 presentations: Pick<ReturnType<typeof createEnvironmentPresentationAtoms>, "presentationAtom">;
26 environmentIds: readonly EnvironmentId[];
27 input: UsageSummaryInput;
28}): Promise<void> {
29 await Promise.all(
30 environmentIds.map(async (environmentId) => {
31 const query = server.usageSummary({ environmentId, input });
32 const presentation = presentations.presentationAtom(environmentId);
33 const controller = new AbortController();
34 const abortWhenDisconnected = () => {
35 if (registry.get(presentation)?.connection.phase !== "connected") controller.abort();
36 };
37 const unsubscribe = registry.subscribe(presentation, abortWhenDisconnected);
38 abortWhenDisconnected();
39 try {
40 const ratesResult = await runAtomCommand(
41 registry,
42 server.refreshUsageRates,
43 { environmentId, input: {} },
44 { reportFailure: false },
45 );
46 const sessionUnavailable =
47 ratesResult._tag === "Failure" &&
48 isEnvironmentRpcUnavailable(squashAtomCommandFailure(ratesResult));
49 // Invalidate even on failure so reconnects cannot reuse the old summary.
50 registry.refresh(query);
51 if (sessionUnavailable || controller.signal.aborted) return;
52 await executeAtomQuery(registry, query, {
53 reportFailure: false,
54 signal: controller.signal,
55 });
56 } finally {
57 unsubscribe();
58 }
59 }),
60 );
61}