| author | |
| committer | |
| log | f12d39359f0f76a64ff2d77959c5baf821df15be |
| tree | 4cc26db3b3e38c7ff9d3edb5c7f85fbd02b9fa6f |
| parent | be53bbd85044e937c359a89b6004c3d3c38ffcf1 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 11 | 11 | formatUsd, |
| 12 | 12 | makeWindow, |
| 13 | 13 | } from "@t3tools/shared/usageFormat"; |
| 14 | import { useMemo, useState } from "react"; | |
| 14 | import { useMemo, useRef, useState } from "react"; | |
| 15 | 15 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; |
| 16 | 16 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 17 | 17 | |
| ... | ... | @@ -91,10 +91,8 @@ export function UsageRouteScreen() { |
| 91 | 91 | [isPast24Hours, merged.daily, merged.hourly], |
| 92 | 92 | ); |
| 93 | 93 | |
| 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); | |
| 98 | 96 | const showingLimits = tab === "limits"; |
| 99 | 97 | const selectWindow = (days: number) => { |
| 100 | 98 | setWindowSelection({ |
| ... | ... | @@ -103,17 +101,22 @@ export function UsageRouteScreen() { |
| 103 | 101 | }); |
| 104 | 102 | }; |
| 105 | 103 | const refreshWindow = () => { |
| 104 | if (refreshingRef.current) return; | |
| 106 | 105 | const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); |
| 107 | 106 | 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 | |
| 112 | 111 | ) { |
| 113 | refresh(); | |
| 114 | } else { | |
| 115 | 112 | setWindowSelection({ days: windowDays, window: nextWindow }); |
| 116 | 113 | } |
| 114 | refreshingRef.current = true; | |
| 115 | setRefreshingUsage(true); | |
| 116 | void refresh(nextWindow).finally(() => { | |
| 117 | refreshingRef.current = false; | |
| 118 | setRefreshingUsage(false); | |
| 119 | }); | |
| 117 | 120 | }; |
| 118 | 121 | |
| 119 | 122 | return ( |
apps/mobile/src/state/usage.ts+13-22| ... | ... | @@ -16,7 +16,7 @@ import { |
| 16 | 16 | type UsageSummary, |
| 17 | 17 | type UsageSummaryInput, |
| 18 | 18 | } from "@t3tools/contracts"; |
| 19 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | |
| 19 | import { refreshUsage } from "@t3tools/client-runtime/state/usage"; | |
| 20 | 20 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; |
| 21 | 21 | import * as Option from "effect/Option"; |
| 22 | 22 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -72,7 +72,7 @@ export interface UsageView { |
| 72 | 72 | * improve by waiting on them, so they must not read as "still reporting". |
| 73 | 73 | */ |
| 74 | 74 | readonly isPartial: boolean; |
| 75 | readonly refresh: () => void; | |
| 75 | readonly refresh: (input?: UsageSummaryInput) => Promise<void>; | |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | 78 | export function useUsage(input: UsageSummaryInput): UsageView { |
| ... | ... | @@ -98,26 +98,17 @@ export function useUsage(input: UsageSummaryInput): UsageView { |
| 98 | 98 | const atom = usageByWindowAtom(windowKey); |
| 99 | 99 | const environments = useAtomValue(atom); |
| 100 | 100 | |
| 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 | ); | |
| 121 | 112 | |
| 122 | 113 | const merged = useMemo(() => { |
| 123 | 114 | const answered: EnvironmentUsage[] = environments.flatMap((environment) => |
apps/web/src/components/BranchToolbarBranchSelector.tsx+3-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | 3 | import { |
| 3 | 4 | isAtomCommandInterrupted, |
| ... | ... | @@ -5,7 +6,7 @@ import { |
| 5 | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 6 | 7 | import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; |
| 7 | 8 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 8 | import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; | |
| 9 | import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; | |
| 9 | 10 | import { |
| 10 | 11 | useCallback, |
| 11 | 12 | useDeferredValue, |
| ... | ... | @@ -864,7 +865,7 @@ export function BranchToolbarBranchSelector({ |
| 864 | 865 | className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs" |
| 865 | 866 | > |
| 866 | 867 | <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" /> | |
| 868 | 869 | <span className="truncate">Start from origin</span> |
| 869 | 870 | </span> |
| 870 | 871 | <Switch |
apps/web/src/components/DiffPanel.tsx+2-4| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 3 | import type { FileDiffContentsLoader } from "@pierre/diffs"; |
| 3 | 4 | import { useParams } from "@tanstack/react-router"; |
| ... | ... | @@ -17,7 +18,6 @@ import { |
| 17 | 18 | Columns2Icon, |
| 18 | 19 | FolderTreeIcon, |
| 19 | 20 | PilcrowIcon, |
| 20 | RefreshCwIcon, | |
| 21 | 21 | Rows3Icon, |
| 22 | 22 | SearchIcon, |
| 23 | 23 | TextWrapIcon, |
| ... | ... | @@ -766,9 +766,7 @@ export default function DiffPanel({ |
| 766 | 766 | /> |
| 767 | 767 | } |
| 768 | 768 | > |
| 769 | <RefreshCwIcon | |
| 770 | className={cn("size-3.5", branchDiffPreview.isPending && "animate-spin")} | |
| 771 | /> | |
| 769 | <RefreshIcon className="size-3.5" refreshing={branchDiffPreview.isPending} /> | |
| 772 | 770 | </TooltipTrigger> |
| 773 | 771 | <TooltipPopup side="top"> |
| 774 | 772 | {branchDiffPreview.isPending ? "Refreshing diff…" : "Refresh diff"} |
apps/web/src/components/LegacySidebar.tsx+2-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { Spinner } from "~/components/ui/spinner"; | |
| 1 | 2 | import { |
| 2 | 3 | ArchiveIcon, |
| 3 | 4 | ArrowUpDownIcon, |
| ... | ... | @@ -6,7 +7,6 @@ import { |
| 6 | 7 | ContainerIcon, |
| 7 | 8 | FolderPlusIcon, |
| 8 | 9 | Globe2Icon, |
| 9 | LoaderIcon, | |
| 10 | 10 | SearchIcon, |
| 11 | 11 | SquarePenIcon, |
| 12 | 12 | TerminalIcon, |
| ... | ... | @@ -2648,7 +2648,7 @@ function LocalSecondaryStatus() { |
| 2648 | 2648 | variant="default" |
| 2649 | 2649 | className="rounded-2xl border-border/40 bg-accent/40 text-muted-foreground" |
| 2650 | 2650 | > |
| 2651 | <LoaderIcon className="animate-spin" /> | |
| 2651 | <Spinner /> | |
| 2652 | 2652 | <AlertTitle className="text-xs font-medium text-foreground"> |
| 2653 | 2653 | Connecting {connecting.join(", ")} |
| 2654 | 2654 | </AlertTitle> |
apps/web/src/components/chat/ChatComposer.tsx+4-4| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import type { |
| 2 | 3 | ApprovalRequestId, |
| 3 | 4 | AssistantCitation, |
| ... | ... | @@ -789,7 +790,6 @@ import { |
| 789 | 790 | LockIcon, |
| 790 | 791 | LockOpenIcon, |
| 791 | 792 | PenLineIcon, |
| 792 | RotateCcwIcon, | |
| 793 | 793 | SparklesIcon, |
| 794 | 794 | XIcon, |
| 795 | 795 | } from "lucide-react"; |
| ... | ... | @@ -5273,7 +5273,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5273 | 5273 | /> |
| 5274 | 5274 | } |
| 5275 | 5275 | > |
| 5276 | <RotateCcwIcon /> | |
| 5276 | <RefreshIcon /> | |
| 5277 | 5277 | </TooltipTrigger> |
| 5278 | 5278 | <TooltipPopup |
| 5279 | 5279 | side="top" |
| ... | ... | @@ -5352,7 +5352,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5352 | 5352 | /> |
| 5353 | 5353 | } |
| 5354 | 5354 | > |
| 5355 | <RotateCcwIcon /> | |
| 5355 | <RefreshIcon /> | |
| 5356 | 5356 | </TooltipTrigger> |
| 5357 | 5357 | <TooltipPopup |
| 5358 | 5358 | side="top" |
| ... | ... | @@ -5425,7 +5425,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5425 | 5425 | /> |
| 5426 | 5426 | } |
| 5427 | 5427 | > |
| 5428 | <RotateCcwIcon /> | |
| 5428 | <RefreshIcon /> | |
| 5429 | 5429 | </TooltipTrigger> |
| 5430 | 5430 | <TooltipPopup |
| 5431 | 5431 | side="top" |
apps/web/src/components/chat/ComposerActivityStatus.tsx+4-4| ... | ... | @@ -1,13 +1,13 @@ |
| 1 | import { LoaderCircleIcon } from "lucide-react"; | |
| 2 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; | |
| 1 | import { Spinner } from "~/components/ui/spinner"; | |
| 2 | ||
| 3 | 3 | import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync"; |
| 4 | 4 | import { ComposerBanner } from "./ComposerBanner"; |
| 5 | 5 | |
| 6 | 6 | export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase }) { |
| 7 | 7 | return ( |
| 8 | 8 | <ComposerBanner.Row> |
| 9 | <ComposerBanner.Icon ref={observeVisibleAnimation}> | |
| 10 | <LoaderCircleIcon className="motion-safe:visible-animate-spin" /> | |
| 9 | <ComposerBanner.Icon> | |
| 10 | <Spinner /> | |
| 11 | 11 | </ComposerBanner.Icon> |
| 12 | 12 | <ComposerBanner.Content> |
| 13 | 13 | <span |
apps/web/src/components/chat/ComposerServerUpdateStatus.tsx+3-9| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | import { Spinner } from "~/components/ui/spinner"; | |
| 1 | 2 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; |
| 2 | import { CircleAlertIcon, DownloadIcon, LoaderCircleIcon } from "lucide-react"; | |
| 3 | import { CircleAlertIcon, DownloadIcon } from "lucide-react"; | |
| 3 | 4 | import { useId, useState } from "react"; |
| 4 | 5 | |
| 5 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; | |
| 6 | 6 | import { serverUpdateStageLabel } from "../ServerUpdateAction"; |
| 7 | 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 8 | 8 | import { ComposerBanner } from "./ComposerBanner"; |
| ... | ... | @@ -13,13 +13,7 @@ export function ComposerServerUpdateIcon({ |
| 13 | 13 | readonly status: ServerUpdateState["status"]; |
| 14 | 14 | }) { |
| 15 | 15 | 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 />; | |
| 23 | 17 | } |
| 24 | 18 | if (status === "failed") { |
| 25 | 19 | return <CircleAlertIcon aria-hidden className="text-error" />; |
apps/web/src/components/clerk/ClerkUserProfilePage.tsx+3-2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import { RefreshCwIcon } from "lucide-react"; | |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 2 | ||
| 2 | 3 | import type { ReactNode } from "react"; |
| 3 | 4 | |
| 4 | 5 | import { cn } from "../../lib/utils"; |
| ... | ... | @@ -55,7 +56,7 @@ export function ClerkUserProfileRefreshButton({ |
| 55 | 56 | disabled={disabled || isPending} |
| 56 | 57 | onClick={onClick} |
| 57 | 58 | > |
| 58 | <RefreshCwIcon aria-hidden="true" className={cn("size-3.5", isPending && "animate-spin")} /> | |
| 59 | <RefreshIcon aria-hidden="true" className="size-3.5" refreshing={isPending} /> | |
| 59 | 60 | Refresh |
| 60 | 61 | </Button> |
| 61 | 62 | ); |
apps/web/src/components/files/FileBreadcrumbs.tsx+6-4| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 2 | import { Spinner } from "~/components/ui/spinner"; | |
| 1 | 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { ArrowLeftIcon, ChevronRightIcon, LoaderCircleIcon, RotateCwIcon } from "lucide-react"; | |
| 4 | import { ArrowLeftIcon, ChevronRightIcon } from "lucide-react"; | |
| 3 | 5 | import { useEffect, useMemo, useState } from "react"; |
| 4 | 6 | |
| 5 | 7 | import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon"; |
| ... | ... | @@ -124,12 +126,12 @@ function BreadcrumbMenuContent(props: { |
| 124 | 126 | <MenuGroup key={props.directoryPath}> |
| 125 | 127 | {entriesQuery.isPending && entriesQuery.data === null ? ( |
| 126 | 128 | <MenuItem disabled> |
| 127 | <LoaderCircleIcon className="animate-spin" /> | |
| 129 | <Spinner /> | |
| 128 | 130 | Loading folder… |
| 129 | 131 | </MenuItem> |
| 130 | 132 | ) : entriesQuery.error && entriesQuery.data === null ? ( |
| 131 | 133 | <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}> |
| 132 | <RotateCwIcon /> | |
| 134 | <RefreshIcon refreshing={entriesQuery.isPending} /> | |
| 133 | 135 | <span className="min-w-0 flex-1 truncate">Retry loading folder</span> |
| 134 | 136 | </MenuItem> |
| 135 | 137 | ) : !directoryAvailable && !entriesTruncated ? ( |
| ... | ... | @@ -177,7 +179,7 @@ function BreadcrumbMenuContent(props: { |
| 177 | 179 | <> |
| 178 | 180 | <MenuSeparator /> |
| 179 | 181 | <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}> |
| 180 | <RotateCwIcon /> | |
| 182 | <RefreshIcon refreshing={entriesQuery.isPending} /> | |
| 181 | 183 | Refresh failed — retry |
| 182 | 184 | </MenuItem> |
| 183 | 185 | </> |
apps/web/src/components/files/FileBrowserPanel.tsx+3-3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import type { |
| 2 | 3 | ContextMenuItem as TreeContextMenuItem, |
| 3 | 4 | ContextMenuOpenContext as TreeContextMenuOpenContext, |
| ... | ... | @@ -5,7 +6,7 @@ import type { |
| 5 | 6 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; |
| 6 | 7 | import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; |
| 7 | 8 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 8 | import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react"; | |
| 9 | import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; | |
| 9 | 10 | import { useEffect, useMemo, useRef } from "react"; |
| 10 | 11 | |
| 11 | 12 | import { Button } from "~/components/ui/button"; |
| ... | ... | @@ -16,7 +17,6 @@ import { useComposerHandleContext } from "~/composerHandleContext"; |
| 16 | 17 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 17 | 18 | import { useTheme } from "~/hooks/useTheme"; |
| 18 | 19 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; |
| 19 | import { cn } from "~/lib/utils"; | |
| 20 | 20 | import { readLocalApi } from "~/localApi"; |
| 21 | 21 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; |
| 22 | 22 | import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; |
| ... | ... | @@ -57,7 +57,7 @@ function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void } |
| 57 | 57 | /> |
| 58 | 58 | } |
| 59 | 59 | > |
| 60 | <RotateCw className={cn(props.isPending && "animate-spin")} /> | |
| 60 | <RefreshIcon refreshing={props.isPending} /> | |
| 61 | 61 | </TooltipTrigger> |
| 62 | 62 | <TooltipPopup>{props.isPending ? "Refreshing…" : "Refresh files"}</TooltipPopup> |
| 63 | 63 | </Tooltip> |
apps/web/src/components/files/FilePreviewPanel.tsx+6-5| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { Spinner } from "~/components/ui/spinner"; | |
| 1 | 2 | import type { |
| 2 | 3 | ChatFileAttachment, |
| 3 | 4 | EditorId, |
| ... | ... | @@ -18,7 +19,7 @@ import { |
| 18 | 19 | squashAtomCommandFailure, |
| 19 | 20 | } from "@t3tools/client-runtime/state/runtime"; |
| 20 | 21 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; |
| 21 | import { Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; | |
| 22 | import { Code2, Eye, FolderTree, Globe2 } from "lucide-react"; | |
| 22 | 23 | import * as Schema from "effect/Schema"; |
| 23 | 24 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 24 | 25 | |
| ... | ... | @@ -198,7 +199,7 @@ function WorkspaceImagePreview(props: { |
| 198 | 199 | </div> |
| 199 | 200 | ) : ( |
| 200 | 201 | <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" /> | |
| 202 | 203 | </div> |
| 203 | 204 | ); |
| 204 | 205 | } |
| ... | ... | @@ -252,7 +253,7 @@ function AttachmentBrowserPreview(props: { |
| 252 | 253 | if (assetUrl._tag !== "Success") { |
| 253 | 254 | return ( |
| 254 | 255 | <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" /> | |
| 256 | 257 | </div> |
| 257 | 258 | ); |
| 258 | 259 | } |
| ... | ... | @@ -308,7 +309,7 @@ function WorkspaceBrowserPreview(props: { |
| 308 | 309 | if (assetUrl._tag !== "Success") { |
| 309 | 310 | return ( |
| 310 | 311 | <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" /> | |
| 312 | 313 | </div> |
| 313 | 314 | ); |
| 314 | 315 | } |
| ... | ... | @@ -1262,7 +1263,7 @@ export default function FilePreviewPanel({ |
| 1262 | 1263 | </div> |
| 1263 | 1264 | ) : relativePath && file.data === null ? ( |
| 1264 | 1265 | <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" /> | |
| 1266 | 1267 | </div> |
| 1267 | 1268 | ) : relativePath && file.data ? ( |
| 1268 | 1269 | isMarkdown && renderMarkdown ? ( |
apps/web/src/components/onboarding/FirstRunGate.tsx+2-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 3 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 3 | 4 | import { Atom } from "effect/unstable/reactivity"; |
| 4 | import { RotateCcwIcon } from "lucide-react"; | |
| 5 | 5 | import { useEffect, useLayoutEffect, useState } from "react"; |
| 6 | 6 | |
| 7 | 7 | import { |
| ... | ... | @@ -235,7 +235,7 @@ function FirstRunRecovery({ |
| 235 | 235 | } |
| 236 | 236 | }} |
| 237 | 237 | > |
| 238 | <RotateCcwIcon /> | |
| 238 | <RefreshIcon refreshing={retrying} /> | |
| 239 | 239 | {settingsReadFailed ? "Retry" : "Reload"} |
| 240 | 240 | </Button> |
| 241 | 241 | </div> |
apps/web/src/components/preview/PreviewChromeRow.tsx+2-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import { |
| 2 | 3 | ArrowLeft, |
| 3 | 4 | ArrowRight, |
| ... | ... | @@ -5,7 +6,6 @@ import { |
| 5 | 6 | ExternalLink, |
| 6 | 7 | MousePointerClick, |
| 7 | 8 | PictureInPicture2, |
| 8 | RotateCw, | |
| 9 | 9 | } from "lucide-react"; |
| 10 | 10 | import { |
| 11 | 11 | type FormEvent, |
| ... | ... | @@ -166,7 +166,7 @@ export function PreviewChromeRow({ |
| 166 | 166 | /> |
| 167 | 167 | } |
| 168 | 168 | > |
| 169 | <RotateCw className={cn(loading && "animate-spin")} /> | |
| 169 | <RefreshIcon refreshing={loading} /> | |
| 170 | 170 | </TooltipTrigger> |
| 171 | 171 | <TooltipPopup>{loading ? "Loading…" : "Refresh"}</TooltipPopup> |
| 172 | 172 | </Tooltip> |
apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx+2-2| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { RefreshCwIcon } from "lucide-react"; | |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 2 | 2 | |
| 3 | 3 | import { cn } from "~/lib/utils"; |
| 4 | 4 | |
| ... | ... | @@ -23,7 +23,7 @@ export function PullRequestActivityUnavailableState({ |
| 23 | 23 | <p className="text-sm font-medium text-foreground">Could not load pull request activity</p> |
| 24 | 24 | <p className="max-w-md text-xs text-muted-foreground">{error}</p> |
| 25 | 25 | <Button size="sm" variant="outline" onClick={onRetry}> |
| 26 | <RefreshCwIcon aria-hidden className="size-3.5" /> | |
| 26 | <RefreshIcon aria-hidden className="size-3.5" /> | |
| 27 | 27 | Retry |
| 28 | 28 | </Button> |
| 29 | 29 | </div> |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+19-6| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 3 | 4 | import { |
| ... | ... | @@ -36,7 +37,6 @@ import { |
| 36 | 37 | PanelRightIcon, |
| 37 | 38 | PencilIcon, |
| 38 | 39 | PlayIcon, |
| 39 | RefreshCwIcon, | |
| 40 | 40 | RotateCcwIcon, |
| 41 | 41 | TriangleAlertIcon, |
| 42 | 42 | } from "lucide-react"; |
| ... | ... | @@ -728,10 +728,16 @@ export function PullRequestDetailPanel({ |
| 728 | 728 | // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run |
| 729 | 729 | // and at worst answer from it. |
| 730 | 730 | const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); |
| 731 | const [isInvalidating, setIsInvalidating] = useState(false); | |
| 731 | 732 | 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 | } | |
| 735 | 741 | }, [environmentId, invalidate, reference, refreshDetail]); |
| 736 | 742 | // A refresh asked for by the page: the detail, and through the token below, the diff with it. |
| 737 | 743 | const appliedForcedToken = useRef(forcedRefreshToken); |
| ... | ... | @@ -1670,8 +1676,14 @@ export function PullRequestDetailPanel({ |
| 1670 | 1676 | <MoreHorizontalIcon className="size-4" /> |
| 1671 | 1677 | </MenuTrigger> |
| 1672 | 1678 | <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 | /> | |
| 1675 | 1687 | Refresh |
| 1676 | 1688 | </MenuItem> |
| 1677 | 1689 | <MenuItem disabled={handoff !== null} onClick={askAboutPullRequest}> |
| ... | ... | @@ -2315,6 +2327,7 @@ export function PullRequestDetailPanel({ |
| 2315 | 2327 | {detailQuery.error && !detail ? ( |
| 2316 | 2328 | <PullRequestsUnavailableState |
| 2317 | 2329 | error={detailQuery.error} |
| 2330 | refreshing={detailQuery.isPending} | |
| 2318 | 2331 | onRetry={refreshDetail} |
| 2319 | 2332 | {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})} |
| 2320 | 2333 | /> |
apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx+4-3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | /** |
| 2 | 3 | * What the list shows when it has no rows to show. |
| 3 | 4 | * |
| ... | ... | @@ -11,7 +12,7 @@ |
| 11 | 12 | * with no project to read from — leave the button out, since pressing it could only repeat what |
| 12 | 13 | * is already happening or ask nobody. |
| 13 | 14 | */ |
| 14 | import { PlusIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; | |
| 15 | import { PlusIcon, SearchIcon } from "lucide-react"; | |
| 15 | 16 | |
| 16 | 17 | import { openCommandPalette } from "../../commandPaletteBus"; |
| 17 | 18 | import { Button } from "../ui/button"; |
| ... | ... | @@ -149,7 +150,7 @@ export function PullRequestListEmptyState({ |
| 149 | 150 | {/* The hosts answered this query once; a pull request opened since then would answer |
| 150 | 151 | differently, and nothing on screen says which of the two the reader is looking at. */} |
| 151 | 152 | <Button size="sm" variant="outline" disabled={refreshing} onClick={onRefresh}> |
| 152 | <RefreshCwIcon className="size-3.5" /> | |
| 153 | <RefreshIcon className="size-3.5" refreshing={refreshing} /> | |
| 153 | 154 | {refreshing ? "Checking..." : "Check again"} |
| 154 | 155 | </Button> |
| 155 | 156 | </EmptyContent> |
| ... | ... | @@ -175,7 +176,7 @@ export function PullRequestListEmptyState({ |
| 175 | 176 | </Button> |
| 176 | 177 | ) : null} |
| 177 | 178 | <Button size="sm" variant="outline" disabled={refreshing} onClick={onRefresh}> |
| 178 | <RefreshCwIcon className="size-3.5" /> | |
| 179 | <RefreshIcon className="size-3.5" refreshing={refreshing} /> | |
| 179 | 180 | {refreshing ? "Checking..." : "Check again"} |
| 180 | 181 | </Button> |
| 181 | 182 | </EmptyContent> |
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+2-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { Spinner } from "~/components/ui/spinner"; | |
| 1 | 2 | import type { |
| 2 | 3 | EnvironmentId, |
| 3 | 4 | ProjectId, |
| ... | ... | @@ -17,7 +18,6 @@ import { |
| 17 | 18 | GitPullRequestDraftIcon, |
| 18 | 19 | LayersIcon, |
| 19 | 20 | ListFilterIcon, |
| 20 | LoaderIcon, | |
| 21 | 21 | SearchIcon, |
| 22 | 22 | TagIcon, |
| 23 | 23 | UserRoundIcon, |
| ... | ... | @@ -120,7 +120,7 @@ export function PullRequestSearchInput({ |
| 120 | 120 | return ( |
| 121 | 121 | <InputGroup className="min-w-0 flex-1 **:[input]:h-9 sm:**:[input]:h-8"> |
| 122 | 122 | <InputGroupAddon> |
| 123 | {busy ? <LoaderIcon aria-hidden className="animate-spin" /> : <SearchIcon aria-hidden />} | |
| 123 | {busy ? <Spinner aria-hidden /> : <SearchIcon aria-hidden />} | |
| 124 | 124 | </InputGroupAddon> |
| 125 | 125 | <InputGroupInput |
| 126 | 126 | type="search" |
apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx+12-3| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import { ExternalLinkIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; | |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 2 | import { ExternalLinkIcon, GitPullRequestIcon } from "lucide-react"; | |
| 2 | 3 | |
| 3 | 4 | import { Button } from "../ui/button"; |
| 4 | 5 | import { |
| ... | ... | @@ -14,11 +15,13 @@ export function PullRequestsUnavailableState({ |
| 14 | 15 | title = "Could not load pull requests", |
| 15 | 16 | error, |
| 16 | 17 | onRetry, |
| 18 | refreshing = false, | |
| 17 | 19 | gitHubUrl, |
| 18 | 20 | }: { |
| 19 | 21 | title?: string; |
| 20 | 22 | error: string; |
| 21 | 23 | onRetry?: () => void; |
| 24 | refreshing?: boolean; | |
| 22 | 25 | gitHubUrl?: string; |
| 23 | 26 | }) { |
| 24 | 27 | return ( |
| ... | ... | @@ -35,8 +38,14 @@ export function PullRequestsUnavailableState({ |
| 35 | 38 | {onRetry || gitHubUrl ? ( |
| 36 | 39 | <EmptyContent className="flex-row flex-wrap justify-center gap-2"> |
| 37 | 40 | {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} /> | |
| 40 | 49 | Retry |
| 41 | 50 | </Button> |
| 42 | 51 | ) : null} |
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+3-3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { Spinner } from "~/components/ui/spinner"; | |
| 1 | 2 | import type { |
| 2 | 3 | PullRequestActor, |
| 3 | 4 | PullRequestCheck, |
| ... | ... | @@ -15,7 +16,6 @@ import { |
| 15 | 16 | GitPullRequestClosedIcon, |
| 16 | 17 | GitPullRequestDraftIcon, |
| 17 | 18 | GitPullRequestIcon, |
| 18 | LoaderIcon, | |
| 19 | 19 | TriangleAlertIcon, |
| 20 | 20 | } from "lucide-react"; |
| 21 | 21 | import { Children, isValidElement, type ReactNode } from "react"; |
| ... | ... | @@ -119,7 +119,7 @@ export function PullRequestStateGlyph({ |
| 119 | 119 | } |
| 120 | 120 | |
| 121 | 121 | const 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" }, | |
| 123 | 123 | "action-required": { |
| 124 | 124 | label: "Awaiting action", |
| 125 | 125 | Icon: CircleDotIcon, |
| ... | ... | @@ -136,7 +136,7 @@ const CHECK_STATUS_PRESENTATION = { |
| 136 | 136 | neutral: { label: "Neutral", Icon: CircleDashedIcon, toneClassName: "text-muted-foreground/70" }, |
| 137 | 137 | } as const satisfies Record< |
| 138 | 138 | PullRequestCheckStatus, |
| 139 | { label: string; Icon: typeof CircleCheckIcon; toneClassName: string } | |
| 139 | { label: string; Icon: typeof CircleCheckIcon | typeof Spinner; toneClassName: string } | |
| 140 | 140 | >; |
| 141 | 141 | |
| 142 | 142 | function isWorkflowApprovalCheck(check: Pick<PullRequestCheck, "status" | "url">): boolean { |
apps/web/src/components/search/ProjectContentSearchDialog.tsx+3-2| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | import { Spinner } from "~/components/ui/spinner"; | |
| 1 | 2 | import type { ProjectContentMatch } from "@t3tools/contracts"; |
| 2 | import { LoaderCircle } from "lucide-react"; | |
| 3 | ||
| 3 | 4 | import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; |
| 4 | 5 | |
| 5 | 6 | import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; |
| ... | ... | @@ -225,7 +226,7 @@ function OpenContentSearchDialog(props: { |
| 225 | 226 | <div className="flex h-9 shrink-0 items-center border-b px-3 text-xs text-muted-foreground"> |
| 226 | 227 | {search.isPending ? ( |
| 227 | 228 | <span className="flex items-center gap-2"> |
| 228 | <LoaderCircle className="size-3.5 animate-spin" /> Searching… | |
| 229 | <Spinner className="size-3.5" /> Searching… | |
| 229 | 230 | </span> |
| 230 | 231 | ) : search.error ? ( |
| 231 | 232 | <span className="text-destructive">{search.error}</span> |
apps/web/src/components/settings/DiagnosticsSettings.tsx+2-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import { |
| 2 | 3 | AlertTriangleIcon, |
| 3 | 4 | ChevronDownIcon, |
| ... | ... | @@ -5,7 +6,6 @@ import { |
| 5 | 6 | CopyIcon, |
| 6 | 7 | FolderOpenIcon, |
| 7 | 8 | InfoIcon, |
| 8 | RefreshCwIcon, | |
| 9 | 9 | } from "lucide-react"; |
| 10 | 10 | import { useAtomValue } from "@effect/atom-react"; |
| 11 | 11 | import { |
| ... | ... | @@ -766,7 +766,7 @@ function DiagnosticsRefreshButton({ |
| 766 | 766 | onClick={onClick} |
| 767 | 767 | aria-label={label} |
| 768 | 768 | > |
| 769 | <RefreshCwIcon className={cn(isPending && "animate-spin")} /> | |
| 769 | <RefreshIcon refreshing={isPending} /> | |
| 770 | 770 | </Button> |
| 771 | 771 | } |
| 772 | 772 | /> |
apps/web/src/components/settings/ProviderInstanceCard.tsx+3-2| ... | ... | @@ -1,10 +1,11 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import { Spinner } from "~/components/ui/spinner"; | |
| 4 | ||
| 3 | 5 | import { |
| 4 | 6 | ArrowUpCircleIcon, |
| 5 | 7 | CopyIcon, |
| 6 | 8 | DownloadIcon, |
| 7 | LoaderIcon, | |
| 8 | 9 | LockIcon, |
| 9 | 10 | LockOpenIcon, |
| 10 | 11 | PlusIcon, |
| ... | ... | @@ -739,7 +740,7 @@ export function ProviderInstanceCard({ |
| 739 | 740 | disabled={isUpdating} |
| 740 | 741 | onClick={onRunUpdate} |
| 741 | 742 | > |
| 742 | {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />} | |
| 743 | {isUpdating ? <Spinner /> : <DownloadIcon />} | |
| 743 | 744 | {isUpdating ? "Updating" : "Update now"} |
| 744 | 745 | </Button> |
| 745 | 746 | ) : null} |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+3-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 3 | import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; |
| 3 | 4 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| ... | ... | @@ -24,7 +25,7 @@ import * as Arr from "effect/Array"; |
| 24 | 25 | import * as Duration from "effect/Duration"; |
| 25 | 26 | import * as Equal from "effect/Equal"; |
| 26 | 27 | import * as Result from "effect/Result"; |
| 27 | import { PlusIcon, RefreshCwIcon } from "lucide-react"; | |
| 28 | import { PlusIcon } from "lucide-react"; | |
| 28 | 29 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 29 | 30 | |
| 30 | 31 | import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; |
| ... | ... | @@ -993,7 +994,7 @@ export function EnvironmentProviderSettings({ |
| 993 | 994 | aria-busy={isRefreshingProviders} |
| 994 | 995 | onClick={() => void refreshProviders()} |
| 995 | 996 | > |
| 996 | <RefreshCwIcon /> | |
| 997 | <RefreshIcon refreshing={isRefreshingProviders} /> | |
| 997 | 998 | <span className="sr-only">Refresh provider status</span> |
| 998 | 999 | <span className="hidden min-w-0 truncate sm:inline"> |
| 999 | 1000 | {isRefreshingProviders ? ( |
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+4-7| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import { |
| 2 | 3 | ActivityIcon, |
| 3 | 4 | AlertTriangleIcon, |
| ... | ... | @@ -9,8 +10,6 @@ import { |
| 9 | 10 | GaugeIcon, |
| 10 | 11 | HardDriveIcon, |
| 11 | 12 | MemoryStickIcon, |
| 12 | RefreshCwIcon, | |
| 13 | RotateCcwIcon, | |
| 14 | 13 | } from "lucide-react"; |
| 15 | 14 | import type { |
| 16 | 15 | BackgroundBooleanState, |
| ... | ... | @@ -982,9 +981,7 @@ export function ResourceTelemetryDiagnostics() { |
| 982 | 981 | onClick={telemetry.refresh} |
| 983 | 982 | aria-label="Refresh resource telemetry" |
| 984 | 983 | > |
| 985 | <RefreshCwIcon | |
| 986 | className={cn("size-3", telemetry.isPending && "animate-spin")} | |
| 987 | /> | |
| 984 | <RefreshIcon className="size-3" refreshing={telemetry.isPending} /> | |
| 988 | 985 | </Button> |
| 989 | 986 | } |
| 990 | 987 | /> |
| ... | ... | @@ -1095,7 +1092,7 @@ export function ResourceTelemetryDiagnostics() { |
| 1095 | 1092 | headerAction={ |
| 1096 | 1093 | collectorNeedsRetry ? ( |
| 1097 | 1094 | <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} /> | |
| 1099 | 1096 | Retry monitor |
| 1100 | 1097 | </Button> |
| 1101 | 1098 | ) : null |
| ... | ... | @@ -1233,7 +1230,7 @@ export function ResourceTelemetryDiagnostics() { |
| 1233 | 1230 | onClick={history.refresh} |
| 1234 | 1231 | aria-label="Refresh resource history" |
| 1235 | 1232 | > |
| 1236 | <RefreshCwIcon className={cn("size-3", history.isPending && "animate-spin")} /> | |
| 1233 | <RefreshIcon className="size-3" refreshing={history.isPending} /> | |
| 1237 | 1234 | </Button> |
| 1238 | 1235 | </div> |
| 1239 | 1236 | } |
apps/web/src/components/settings/SettingsPanels.tsx+3-2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react"; | |
| 1 | import { Spinner } from "~/components/ui/spinner"; | |
| 2 | import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; | |
| 2 | 3 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | 4 | import type { CSSProperties, ReactNode } from "react"; |
| 4 | 5 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| ... | ... | @@ -3029,7 +3030,7 @@ export function ArchivedThreadsPanel() { |
| 3029 | 3030 | title={ |
| 3030 | 3031 | <span className="inline-flex items-center gap-2"> |
| 3031 | 3032 | {isLoadingArchive ? ( |
| 3032 | <LoaderIcon className="size-3.5 animate-spin text-muted-foreground" /> | |
| 3033 | <Spinner className="size-3.5 text-muted-foreground" /> | |
| 3033 | 3034 | ) : ( |
| 3034 | 3035 | <ArchiveIcon className="size-3.5 text-muted-foreground" /> |
| 3035 | 3036 | )} |
apps/web/src/components/settings/SourceControlSettings.tsx+4-3| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; | |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 2 | import { ChevronDownIcon, GitPullRequestIcon } from "lucide-react"; | |
| 2 | 3 | import * as Duration from "effect/Duration"; |
| 3 | 4 | import * as Option from "effect/Option"; |
| 4 | 5 | import { useEffect, useState, type ReactNode } from "react"; |
| ... | ... | @@ -487,7 +488,7 @@ function EmptySourceControlDiscovery({ |
| 487 | 488 | </EmptyHeader> |
| 488 | 489 | <EmptyContent> |
| 489 | 490 | <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} /> | |
| 491 | 492 | Scan |
| 492 | 493 | </Button> |
| 493 | 494 | </EmptyContent> |
| ... | ... | @@ -532,7 +533,7 @@ export function SourceControlSettingsPanel() { |
| 532 | 533 | disabled={discovery.isPending} |
| 533 | 534 | aria-label="Rescan server environment" |
| 534 | 535 | > |
| 535 | <RefreshCwIcon className={cn(discovery.isPending && "animate-spin")} /> | |
| 536 | <RefreshIcon refreshing={discovery.isPending} /> | |
| 536 | 537 | </Button> |
| 537 | 538 | } |
| 538 | 539 | /> |
apps/web/src/components/settings/ThemeSearchSection.tsx+3-8| ... | ... | @@ -1,10 +1,5 @@ |
| 1 | import { | |
| 2 | ExternalLinkIcon, | |
| 3 | PackagePlusIcon, | |
| 4 | PaletteIcon, | |
| 5 | RefreshCwIcon, | |
| 6 | SearchIcon, | |
| 7 | } from "lucide-react"; | |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 2 | import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, SearchIcon } from "lucide-react"; | |
| 8 | 3 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 9 | 4 | import { |
| 10 | 5 | importOpenVsxThemeExtension, |
| ... | ... | @@ -417,7 +412,7 @@ export function ThemeSearchSection({ |
| 417 | 412 | {isInstalling ? ( |
| 418 | 413 | <Spinner /> |
| 419 | 414 | ) : isInstalled ? ( |
| 420 | <RefreshCwIcon /> | |
| 415 | <RefreshIcon /> | |
| 421 | 416 | ) : ( |
| 422 | 417 | <PackagePlusIcon /> |
| 423 | 418 | )} |
apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx+5-5| ... | ... | @@ -1,8 +1,7 @@ |
| 1 | import { CheckIcon, DownloadIcon, RefreshCwIcon, RotateCwIcon } from "lucide-react"; | |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 2 | import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; | |
| 2 | 3 | import type { AnimationEventHandler } from "react"; |
| 3 | 4 | |
| 4 | import { cn } from "../../lib/utils"; | |
| 5 | ||
| 6 | 5 | const DOWNLOAD_PROGRESS_RADIUS = 14; |
| 7 | 6 | const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS; |
| 8 | 7 | |
| ... | ... | @@ -118,8 +117,9 @@ export function DesktopUpdateStatusIcon({ |
| 118 | 117 | if (status === "downloaded") return <DesktopUpdateDownloadedIcon />; |
| 119 | 118 | |
| 120 | 119 | 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} | |
| 123 | 123 | onAnimationIteration={onCheckAnimationIteration} |
| 124 | 124 | /> |
| 125 | 125 | ); |
apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx+3-2| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | import { Spinner } from "~/components/ui/spinner"; | |
| 1 | 2 | import { useNavigate } from "@tanstack/react-router"; |
| 2 | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 4 | import type { ServerProvider } from "@t3tools/contracts"; |
| 4 | import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react"; | |
| 5 | import { CircleCheckIcon, DownloadIcon, TriangleAlertIcon, XIcon } from "lucide-react"; | |
| 5 | 6 | import { useCallback, useEffect, useState, type CSSProperties } from "react"; |
| 6 | 7 | |
| 7 | 8 | import { primaryServerProvidersAtom } from "../../state/server"; |
| ... | ... | @@ -173,7 +174,7 @@ export function SidebarProviderUpdatePill() { |
| 173 | 174 | onClick={openProviderSettings} |
| 174 | 175 | > |
| 175 | 176 | {displayedView.tone === "loading" ? ( |
| 176 | <LoaderIcon className="size-3.5 animate-spin" /> | |
| 177 | <Spinner className="size-3.5" /> | |
| 177 | 178 | ) : displayedView.tone === "success" ? ( |
| 178 | 179 | <CircleCheckIcon className="size-3.5" /> |
| 179 | 180 | ) : displayedView.tone === "error" ? ( |
apps/web/src/components/ui/refresh-icon.tsx created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | import { RefreshCwIcon } from "lucide-react"; | |
| 2 | ||
| 3 | import { cn } from "~/lib/utils"; | |
| 4 | import { observeVisibleAnimation } from "~/lib/visibleAnimation"; | |
| 5 | ||
| 6 | /** Keep the refresh glyph in place while its owning action is running. */ | |
| 7 | export 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 @@ |
| 1 | import { Loader2Icon } from "lucide-react"; | |
| 1 | import { LoaderCircleIcon } from "lucide-react"; | |
| 2 | import { observeVisibleAnimation } from "~/lib/visibleAnimation"; | |
| 2 | 3 | import { cn } from "~/lib/utils"; |
| 3 | 4 | |
| 4 | function Spinner({ className, ...props }: React.ComponentProps<typeof Loader2Icon>) { | |
| 5 | function Spinner({ className, ...props }: React.ComponentPropsWithoutRef<typeof LoaderCircleIcon>) { | |
| 5 | 6 | return ( |
| 6 | <Loader2Icon | |
| 7 | <LoaderCircleIcon | |
| 7 | 8 | aria-label="Loading" |
| 8 | className={cn("animate-spin", className)} | |
| 9 | ref={observeVisibleAnimation} | |
| 10 | className={cn("motion-safe:visible-animate-spin", className)} | |
| 9 | 11 | role="status" |
| 10 | 12 | {...props} |
| 11 | 13 | /> |
apps/web/src/components/ui/toast.tsx+4-3| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import { Spinner } from "~/components/ui/spinner"; | |
| 4 | ||
| 3 | 5 | import { Toast } from "@base-ui/react/toast"; |
| 4 | 6 | import { |
| 5 | 7 | useEffect, |
| ... | ... | @@ -20,7 +22,6 @@ import { |
| 20 | 22 | CircleCheckIcon, |
| 21 | 23 | CopyIcon, |
| 22 | 24 | InfoIcon, |
| 23 | LoaderCircleIcon, | |
| 24 | 25 | TriangleAlertIcon, |
| 25 | 26 | XIcon, |
| 26 | 27 | } from "lucide-react"; |
| ... | ... | @@ -83,7 +84,7 @@ const threadToastVisibleTimeoutRemainingMs = new Map<ToastId, number>(); |
| 83 | 84 | const TOAST_ICONS = { |
| 84 | 85 | error: CircleAlertIcon, |
| 85 | 86 | info: InfoIcon, |
| 86 | loading: LoaderCircleIcon, | |
| 87 | loading: Spinner, | |
| 87 | 88 | success: CircleCheckIcon, |
| 88 | 89 | warning: TriangleAlertIcon, |
| 89 | 90 | } as const; |
| ... | ... | @@ -357,7 +358,7 @@ function ToastBodyContent({ |
| 357 | 358 | className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" |
| 358 | 359 | data-slot="toast-icon" |
| 359 | 360 | > |
| 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" /> | |
| 361 | 362 | </div> |
| 362 | 363 | ) : null} |
| 363 | 364 | <div |
apps/web/src/components/usage/UsagePage.tsx+35-16| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 3 | import { |
| 3 | 4 | USAGE_CONTRACT_VERSION, |
| ... | ... | @@ -8,10 +9,9 @@ import { |
| 8 | 9 | CircleAlertIcon, |
| 9 | 10 | ChevronDownIcon, |
| 10 | 11 | CircleDashedIcon, |
| 11 | RefreshCwIcon, | |
| 12 | 12 | SlidersHorizontalIcon, |
| 13 | 13 | } from "lucide-react"; |
| 14 | import { useMemo, useState } from "react"; | |
| 14 | import { useMemo, useRef, useState } from "react"; | |
| 15 | 15 | |
| 16 | 16 | import { |
| 17 | 17 | isCompatibleUsageContractVersion, |
| ... | ... | @@ -88,6 +88,8 @@ export function UsagePage() { |
| 88 | 88 | })); |
| 89 | 89 | const [metric, setMetric] = useState<UsageMetric>("cost"); |
| 90 | 90 | const showingLimits = metric === "limits"; |
| 91 | const [isRefreshing, setIsRefreshing] = useState(false); | |
| 92 | const refreshingRef = useRef(false); | |
| 91 | 93 | const [breakdown, setBreakdown] = useState<"model" | "time">("model"); |
| 92 | 94 | const [selectedEnvironmentIds, setSelectedEnvironmentIds] = |
| 93 | 95 | useState<ReadonlySet<EnvironmentId> | null>(null); |
| ... | ... | @@ -138,26 +140,39 @@ export function UsagePage() { |
| 138 | 140 | }); |
| 139 | 141 | }; |
| 140 | 142 | const refreshWindow = () => { |
| 143 | if (refreshingRef.current) return; | |
| 144 | ||
| 141 | 145 | 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 | }); | |
| 148 | 159 | return; |
| 149 | 160 | } |
| 150 | 161 | const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); |
| 151 | 162 | 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 | |
| 156 | 167 | ) { |
| 157 | refresh(); | |
| 158 | } else { | |
| 159 | 168 | setWindowSelection({ days: windowDays, window: nextWindow }); |
| 160 | 169 | } |
| 170 | refreshingRef.current = true; | |
| 171 | setIsRefreshing(true); | |
| 172 | void refresh(nextWindow).finally(() => { | |
| 173 | refreshingRef.current = false; | |
| 174 | setIsRefreshing(false); | |
| 175 | }); | |
| 161 | 176 | }; |
| 162 | 177 | const windowLabel = |
| 163 | 178 | isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined |
| ... | ... | @@ -225,10 +240,12 @@ export function UsagePage() { |
| 225 | 240 | <Button |
| 226 | 241 | onClick={refreshWindow} |
| 227 | 242 | aria-label={showingLimits ? "Refresh limits" : "Refresh usage"} |
| 243 | aria-busy={isRefreshing} | |
| 244 | disabled={isRefreshing} | |
| 228 | 245 | size="icon-sm" |
| 229 | 246 | variant="ghost" |
| 230 | 247 | > |
| 231 | <RefreshCwIcon className="size-3.5" /> | |
| 248 | <RefreshIcon className="size-3.5" refreshing={isRefreshing} /> | |
| 232 | 249 | </Button> |
| 233 | 250 | </div> |
| 234 | 251 | <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() { |
| 282 | 299 | <Button |
| 283 | 300 | onClick={refreshWindow} |
| 284 | 301 | aria-label={showingLimits ? "Refresh limits" : "Refresh usage"} |
| 302 | aria-busy={isRefreshing} | |
| 303 | disabled={isRefreshing} | |
| 285 | 304 | size="icon-sm" |
| 286 | 305 | variant="ghost" |
| 287 | 306 | > |
| 288 | <RefreshCwIcon className="size-3.5" /> | |
| 307 | <RefreshIcon className="size-3.5" refreshing={isRefreshing} /> | |
| 289 | 308 | </Button> |
| 290 | 309 | </div> |
| 291 | 310 | </div> |
apps/web/src/routes/_chat.index.tsx+3-2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 1 | 2 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | 3 | import { createFileRoute, Link } from "@tanstack/react-router"; |
| 3 | import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react"; | |
| 4 | import { LinkIcon, PlusIcon } from "lucide-react"; | |
| 4 | 5 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | 6 | |
| 6 | 7 | import { openCommandPalette } from "../commandPaletteBus"; |
| ... | ... | @@ -96,7 +97,7 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) { |
| 96 | 97 | </EmptyDescription> |
| 97 | 98 | <div className="mt-5 flex justify-center"> |
| 98 | 99 | <Button size="sm" onClick={onRetry}> |
| 99 | <RotateCcwIcon className="size-4" /> | |
| 100 | <RefreshIcon className="size-4" /> | |
| 100 | 101 | Try again |
| 101 | 102 | </Button> |
| 102 | 103 | </div> |
apps/web/src/routes/_chat.pull-requests.tsx+9-5| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | |
| 2 | import { Spinner } from "~/components/ui/spinner"; | |
| 1 | 3 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | 4 | import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts"; |
| 3 | 5 | import type { |
| ... | ... | @@ -26,10 +28,8 @@ import { |
| 26 | 28 | LayersIcon, |
| 27 | 29 | ListChecksIcon, |
| 28 | 30 | PenLineIcon, |
| 29 | LoaderIcon, | |
| 30 | 31 | Maximize2Icon, |
| 31 | 32 | Minimize2Icon, |
| 32 | RefreshCwIcon, | |
| 33 | 33 | SearchIcon, |
| 34 | 34 | } from "lucide-react"; |
| 35 | 35 | import { |
| ... | ... | @@ -1581,7 +1581,11 @@ function PullRequestsRouteView() { |
| 1581 | 1581 | ) : firstLoad ? ( |
| 1582 | 1582 | <PullRequestListGhost rows={7} /> |
| 1583 | 1583 | ) : 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 | /> | |
| 1585 | 1589 | ) : carriedToNothing ? ( |
| 1586 | 1590 | <PullRequestListGhost rows={7} /> |
| 1587 | 1591 | ) : entries.length === 0 ? ( |
| ... | ... | @@ -1658,7 +1662,7 @@ function PullRequestsRouteView() { |
| 1658 | 1662 | <div className="flex justify-center py-3 text-xs text-muted-foreground"> |
| 1659 | 1663 | {loadingMore ? ( |
| 1660 | 1664 | <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" /> | |
| 1662 | 1666 | {sentCursors === null ? "Updating pull requests" : "Loading more"} |
| 1663 | 1667 | </span> |
| 1664 | 1668 | ) : canContinue || pageSize < MAX_PAGE_SIZE ? ( |
| ... | ... | @@ -2318,7 +2322,7 @@ function PullRequestRefreshControl({ |
| 2318 | 2322 | onClick={onRefresh} |
| 2319 | 2323 | disabled={refreshing} |
| 2320 | 2324 | > |
| 2321 | <RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} /> | |
| 2325 | <RefreshIcon className="size-4" refreshing={refreshing} /> | |
| 2322 | 2326 | </Button> |
| 2323 | 2327 | ); |
| 2324 | 2328 | } |
apps/web/src/state/usage.ts+13-22| ... | ... | @@ -13,7 +13,7 @@ import { |
| 13 | 13 | type UsageSummary, |
| 14 | 14 | type UsageSummaryInput, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | |
| 16 | import { refreshUsage } from "@t3tools/client-runtime/state/usage"; | |
| 17 | 17 | import * as Option from "effect/Option"; |
| 18 | 18 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 19 | 19 | import { useCallback, useMemo } from "react"; |
| ... | ... | @@ -70,7 +70,7 @@ export interface UsageView { |
| 70 | 70 | * improve by waiting on them, so they must not read as "still reporting". |
| 71 | 71 | */ |
| 72 | 72 | readonly isPartial: boolean; |
| 73 | readonly refresh: () => void; | |
| 73 | readonly refresh: (input?: UsageSummaryInput) => Promise<void>; | |
| 74 | 74 | } |
| 75 | 75 | |
| 76 | 76 | export function useUsage( |
| ... | ... | @@ -108,26 +108,17 @@ export function useUsage( |
| 108 | 108 | [environments, selectedEnvironmentIds], |
| 109 | 109 | ); |
| 110 | 110 | |
| 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 | ); | |
| 131 | 122 | |
| 132 | 123 | const merged = useMemo(() => { |
| 133 | 124 | const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => |
packages/client-runtime/package.json+4| ... | ... | @@ -147,6 +147,10 @@ |
| 147 | 147 | "types": "./src/state/runtime.ts", |
| 148 | 148 | "default": "./src/state/runtime.ts" |
| 149 | 149 | }, |
| 150 | "./state/usage": { | |
| 151 | "types": "./src/state/usage.ts", | |
| 152 | "default": "./src/state/usage.ts" | |
| 153 | }, | |
| 150 | 154 | "./state/server": { |
| 151 | 155 | "types": "./src/state/server.ts", |
| 152 | 156 | "default": "./src/state/server.ts" |
packages/client-runtime/src/state/runtime.test.ts+18| ... | ... | @@ -683,6 +683,24 @@ describe("executeAtomQuery", () => { |
| 683 | 683 | |
| 684 | 684 | registry.dispose(); |
| 685 | 685 | }); |
| 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 | }); | |
| 686 | 704 | }); |
| 687 | 705 | |
| 688 | 706 | describe("runtime command runner", () => { |
packages/client-runtime/src/state/runtime.ts+7-1| ... | ... | @@ -336,6 +336,8 @@ export interface AtomQueryOptions extends AtomCommandOptions { |
| 336 | 336 | * verification flows where a cached failure must not satisfy a retry. |
| 337 | 337 | */ |
| 338 | 338 | readonly refresh?: boolean; |
| 339 | /** Interrupt the query wait when its caller no longer wants the result. */ | |
| 340 | readonly signal?: AbortSignal; | |
| 339 | 341 | } |
| 340 | 342 | |
| 341 | 343 | export async function executeAtomQuery<A, E>( |
| ... | ... | @@ -362,7 +364,11 @@ export async function executeAtomQuery<A, E>( |
| 362 | 364 | }); |
| 363 | 365 | }), |
| 364 | 366 | ); |
| 365 | return executeAtomCommand(() => Effect.runPromiseExit(query), options, reporter); | |
| 367 | return executeAtomCommand( | |
| 368 | () => Effect.runPromiseExit(query, { signal: options.signal }), | |
| 369 | options, | |
| 370 | reporter, | |
| 371 | ); | |
| 366 | 372 | } |
| 367 | 373 | |
| 368 | 374 | export function createRuntimeCommand<R, ER, W, A, E>( |
packages/client-runtime/src/state/usage.test.ts created+184| ... | ... | @@ -0,0 +1,184 @@ |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | UsageDay, | |
| 4 | USAGE_CONTRACT_VERSION, | |
| 5 | type UsageSummary, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | |
| 9 | import { afterEach, describe, expect, it } from "vite-plus/test"; | |
| 10 | ||
| 11 | import type { EnvironmentPresentation } from "../connection/presentation.ts"; | |
| 12 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; | |
| 13 | import { refreshUsage } from "./usage.ts"; | |
| 14 | ||
| 15 | const input = { | |
| 16 | sinceDay: UsageDay.make("2026-09-05"), | |
| 17 | untilDay: UsageDay.make("2026-09-05"), | |
| 18 | timeZone: "UTC", | |
| 19 | }; | |
| 20 | const pricing = { status: "fresh" as const, source: "test", fetchedAt: null, knownModels: 1 }; | |
| 21 | const 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 | }; | |
| 30 | const registries: AtomRegistry.AtomRegistry[] = []; | |
| 31 | afterEach(() => { | |
| 32 | for (const registry of registries.splice(0)) registry.dispose(); | |
| 33 | }); | |
| 34 | ||
| 35 | function 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 | ||
| 83 | describe("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 @@ |
| 1 | import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | import type { AtomRegistry } from "effect/unstable/reactivity"; | |
| 4 | ||
| 5 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; | |
| 6 | import type { createEnvironmentPresentationAtoms } from "./presentation.ts"; | |
| 7 | import { executeAtomQuery, runAtomCommand, squashAtomCommandFailure } from "./runtime.ts"; | |
| 8 | import type { createServerEnvironmentAtoms } from "./server.ts"; | |
| 9 | ||
| 10 | const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); | |
| 11 | ||
| 12 | /** Refresh pricing, then await each selected environment's rescan while it remains connected. */ | |
| 13 | export 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 | } |