Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/logs-skeleton-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- **Console** · Usage logs: the request history now fades in once after the skeleton instead of flashing; paging and filtering keep the current rows (dimmed) while loading rather than replaying the skeleton. Other pages with a full-page skeleton fade in the same way.

### 中文

- **界面** · 使用日志:请求历史在骨架屏结束后只淡入一次,不再闪烁;翻页、筛选时保留当前列表(变暗)等待加载,不再重播骨架屏动画。其他整页骨架屏的页面也统一为单次淡入。
68 changes: 54 additions & 14 deletions frontend/src/hooks/useSkeletonSwap.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { useLayoutEffect, useRef } from 'react'
import { duration, ease, gsap, prefersReducedMotion } from '@/lib/motion'
import { duration, prefersReducedMotion } from '@/lib/motion'

// CSS equivalent of ease.out (power3.out).
const EASE_OUT = 'cubic-bezier(0.215, 0.61, 0.355, 1)'

// Animations run through WAAPI rather than GSAP on purpose: content that
// replaces a skeleton (a 50-row table, a whole page) usually costs one long
// frame to mount. GSAP's ticker measures that frame as elapsed time and jumps
// a 280ms tween straight to its end, so the content showed at opacity 0 for a
// frame and then popped in — a visible flash instead of a fade. WAAPI starts
// the timeline on the first frame it actually paints, so the fade plays in full.
function fadeIn(el: Element, ms: number) {
return el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: ms, easing: EASE_OUT })
}

// After arc's skeleton: when `loading` turns false, every element marked
// data-skeleton-swap="<name>" springs from the skeleton's height to the
// content's height (then back to auto) while the content fades in.
// data-skeleton-swap="<name>" that showed a skeleton springs from the
// skeleton's height to the content's height while the content fades in.
// Elements that were not on screen during loading (e.g. the whole page was a
// skeleton) are skipped — useSkeletonExit fades those in once at page level.
export function useSkeletonSwap(name: string, loading: boolean) {
const heights = useRef(new WeakMap<HTMLElement, number>())
const wasLoading = useRef(loading)
Expand All @@ -17,22 +32,47 @@ export function useSkeletonSwap(name: string, loading: boolean) {
}
if (!wasLoading.current) return
wasLoading.current = false
const measured = heights.current
heights.current = new WeakMap()
if (prefersReducedMotion()) return
const tweens: gsap.core.Tween[] = []
const animations: Animation[] = []
for (const el of elements) {
const from = heights.current.get(el)
const from = measured.get(el)
if (from == null) continue
const to = el.offsetHeight
if (from != null && Math.abs(from - to) > 1) {
tweens.push(gsap.fromTo(el, { height: from, overflow: 'hidden' }, {
height: to,
duration: duration.slow,
ease: ease.out,
clearProps: 'height,overflow',
}))
if (Math.abs(from - to) > 1) {
const overflow = el.style.overflow
el.style.overflow = 'hidden'
const anim = el.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: duration.slow * 1000, easing: EASE_OUT })
anim.finished.catch(() => {}).finally(() => { el.style.overflow = overflow })
animations.push(anim)
}
const content = el.lastElementChild
if (content) tweens.push(gsap.from(content, { autoAlpha: 0, duration: duration.base, ease: ease.out, clearProps: 'opacity,visibility' }))
if (content) animations.push(fadeIn(content, duration.base * 1000))
}
return () => tweens.forEach((tween) => tween.progress(1).kill())
return () => animations.forEach((anim) => anim.finish())
}, [name, loading])
}

// Whole-page skeleton -> content: pass `skeleton` (true while the page renders
// its full-page skeleton) and put the returned ref on the content root. The
// root fades in exactly once, the first time the skeleton gives way.
export function useSkeletonExit<T extends HTMLElement = HTMLDivElement>(skeleton: boolean) {
const ref = useRef<T>(null)
const shown = useRef(skeleton)

useLayoutEffect(() => {
if (skeleton) {
shown.current = true
return
}
if (!shown.current) return
shown.current = false
const el = ref.current
if (!el || prefersReducedMotion()) return
const anim = fadeIn(el, duration.base * 1000)
return () => anim.finish()
}, [skeleton])

return ref
}
4 changes: 3 additions & 1 deletion frontend/src/pages/AccessPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useSkeletonExit } from '@/hooks/useSkeletonSwap'
import { CopyIcon } from '@/components/motion/CopyIcon'
import { copyText } from '@/lib/clipboard'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
Expand Down Expand Up @@ -167,6 +168,7 @@ export function AccessPage() {
[chatEndpoint, payload, selectedAccount],
)

const skeletonExitRef = useSkeletonExit(loading && !overview)
if (loading && !overview) return <AccessPageSkeleton />

async function copy(value: string, kind: 'base' | 'curl') {
Expand Down Expand Up @@ -201,7 +203,7 @@ export function AccessPage() {
: t('requestIdle')

return (
<div className="space-y-5">
<div ref={skeletonExitRef} className="space-y-5">
<section className="grid gap-5 pb-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div data-gsap-reveal>
<h2 className="text-title font-semibold">{t('apiPlayground')}</h2>
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/pages/AccountsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useSkeletonExit } from '@/hooks/useSkeletonSwap'
import { toast } from '@/lib/toast'
import { copyText } from '@/lib/clipboard'
import { useCallback, useEffect, useMemo, useState } from 'react'
Expand Down Expand Up @@ -190,6 +191,7 @@ export function AccountsPage() {
const shownFrom = filteredRows.length === 0 ? 0 : (currentPage - 1) * pageSize + 1
const shownTo = Math.min(filteredRows.length, currentPage * pageSize)

const skeletonExitRef = useSkeletonExit(accountsLoading && !hasAccounts)
if (accountsLoading && !hasAccounts) return <AccountsPageSkeleton />

async function run(id: string, kind: AccountBusyKind, action: () => Promise<void>) {
Expand Down Expand Up @@ -363,7 +365,7 @@ export function AccountsPage() {
}

return (
<div className="space-y-4">
<div ref={skeletonExitRef} className="space-y-4">
{checkinError ? <PageAlert title={checkinError} /> : null}
<section data-gsap-reveal className="grid gap-3 pb-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-body">
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/pages/KeysPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useSkeletonExit } from '@/hooks/useSkeletonSwap'
import { toast } from '@/lib/toast'
import { HoldToConfirm } from '@/components/motion/HoldToConfirm'
import { copyText } from '@/lib/clipboard'
Expand Down Expand Up @@ -170,10 +171,11 @@ export function KeysPage() {
}
}

const skeletonExitRef = useSkeletonExit(loading && !keys.length)
if (loading && !keys.length) return <KeysPageSkeleton />

return (
<div className="space-y-6">
<div ref={skeletonExitRef} className="space-y-6">
<section className="flex flex-wrap items-end justify-between gap-4 pb-4">
<div>
<h2 data-gsap-reveal className="text-title font-semibold">{t('navKeys')}</h2>
Expand Down
20 changes: 13 additions & 7 deletions frontend/src/pages/LogsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { toast } from '@/lib/toast'
import { useTabPanelSlide } from '@/hooks/useTabPanelSlide'
import { useSkeletonSwap } from '@/hooks/useSkeletonSwap'
import { useSkeletonExit, useSkeletonSwap } from '@/hooks/useSkeletonSwap'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getLocalTimeZone, type DateValue } from '@internationalized/date'
import {
Expand Down Expand Up @@ -221,7 +221,6 @@ export function LogsPage() {
const [selected, setSelected] = useState<RequestLog | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
useSkeletonSwap('logs', loading)
useSkeletonSwap('detail', detailLoading)
const [detailError, setDetailError] = useState('')
const detailRequestRef = useRef(0)
Expand Down Expand Up @@ -476,10 +475,17 @@ export function LogsPage() {
}
}

// Refetches (page, filter, refresh) keep the current rows on screen, dimmed,
// instead of swapping back to the skeleton — the skeleton crossfade is for
// an empty list only, so paging no longer replays the entrance animation.
const listSkeleton = loading && (tab === 'requests' ? requests.length === 0 : runtime.length === 0)
const listStale = loading && !listSkeleton
useSkeletonSwap('logs', listSkeleton)
const skeletonExitRef = useSkeletonExit(loading && !booted)
if (loading && !booted) return <LogsPageSkeleton />

return (
<div className="space-y-6">
<div ref={skeletonExitRef} className="space-y-6">
<section className="flex flex-wrap items-end justify-between gap-4 pb-4">
<div>
<h2 data-gsap-reveal className="text-title font-semibold">{t('navLogs')}</h2>
Expand Down Expand Up @@ -711,8 +717,8 @@ export function LogsPage() {
</div>
</div>

<Card data-gsap-reveal data-skeleton-swap="logs" className="overflow-hidden p-0" aria-busy={loading}>
{loading ? (
<Card data-gsap-reveal data-skeleton-swap="logs" className={`overflow-hidden p-0 transition-opacity duration-200 ${listStale ? 'opacity-60' : ''}`} aria-busy={loading}>
{listSkeleton ? (
<LogsRequestListSkeleton />
) : requests.length === 0 ? (
<EmptyPanel
Expand Down Expand Up @@ -844,8 +850,8 @@ export function LogsPage() {
</div>
</div>

<Card data-gsap-reveal data-skeleton-swap="logs" className="overflow-hidden p-0" aria-busy={loading}>
{loading ? (
<Card data-gsap-reveal data-skeleton-swap="logs" className={`overflow-hidden p-0 transition-opacity duration-200 ${listStale ? 'opacity-60' : ''}`} aria-busy={loading}>
{listSkeleton ? (
<LogsRuntimeListSkeleton />
) : runtime.length === 0 ? (
<EmptyPanel icon={<TerminalWindow size={22} />} title={t('logsEmptyRuntime')} />
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/pages/OverviewPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useSkeletonSwap } from '@/hooks/useSkeletonSwap'
import { useSkeletonExit, useSkeletonSwap } from '@/hooks/useSkeletonSwap'
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'
import gsap from 'gsap'
import { Link } from 'react-router-dom'
Expand Down Expand Up @@ -165,10 +165,11 @@ export function OverviewPage() {
{ label: t('metricTokens'), value: traffic.tokens.total, kind: 'compact' as const, detail: `${formatCompact(traffic.tokens.prompt)} / ${formatCompact(traffic.tokens.completion)}`, ok: true, icon: <Coins size={15} />, trend: null as number | null },
]

const skeletonExitRef = useSkeletonExit(loading && !overview)
if (loading && !overview) return <OverviewPageSkeleton />

return (
<div className="space-y-6">
<div ref={skeletonExitRef} className="space-y-6">
<section className="grid gap-4 pb-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div>
<h2 data-gsap-reveal className="text-title font-semibold">{t('homeDisplay')}</h2>
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/pages/ProvidersPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useSkeletonExit } from '@/hooks/useSkeletonSwap'
import { useEffect, useMemo, useState } from 'react'
import { Button, Card, Chip, Table, Tooltip } from '@heroui/react'
import { Cube, ArrowClockwise, MagnifyingGlass, Info } from '@phosphor-icons/react'
Expand Down Expand Up @@ -225,6 +226,7 @@ export function ProvidersPage() {
? t('logsShownTotal', { shown: `${shownFrom}–${shownTo}`, total: filtered.length })
: t('shownTotal', { shown: 0, total: models.length })

const skeletonExitRef = useSkeletonExit((loading && !overview) || modelsLoading)
if ((loading && !overview) || modelsLoading) return <ProvidersPageSkeleton />


Expand Down Expand Up @@ -324,7 +326,7 @@ export function ProvidersPage() {


return (
<div className="space-y-6">
<div ref={skeletonExitRef} className="space-y-6">
<section className="grid gap-5 pb-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div data-gsap-reveal>
<h2 className="text-title font-semibold">{t('availableModels')}</h2>
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/pages/SystemPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useSkeletonExit } from '@/hooks/useSkeletonSwap'
import { copyText } from '@/lib/clipboard'
import { useEffect, useMemo, useState } from 'react'
import { Button, Card, Chip, Description, Input, Label, ListBox, Modal, Select, Tab, Tabs } from '@heroui/react'
Expand Down Expand Up @@ -142,10 +143,11 @@ export function SystemPage() {
}
}

const skeletonExitRef = useSkeletonExit(loading && !info)
if (loading && !info) return <SystemPageSkeleton />

return (
<div className="space-y-6">
<div ref={skeletonExitRef} className="space-y-6">
<section className="flex flex-wrap items-end justify-between gap-4 pb-4">
<div>
<h2 data-gsap-reveal className="text-title font-semibold">{t('systemSettingsTitle')}</h2>
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/webui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
<meta name="twitter:title" content="CLI2API" />
<meta name="twitter:description" content="Qoder CLI → OpenAI-compatible API. Self-hosted gateway." />
<meta name="twitter:image" content="/og-card.svg" />
<script type="module" crossorigin src="/assets/index-BUhcZKuq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-G2li8oay.css">
<script type="module" crossorigin src="/assets/index-CQQZl4FY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DkjgTSmb.css">
</head>
<body>
<div id="root"></div>
Expand Down
Loading