mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 05:20:01 +00:00
feat: Implement useDeferredLoading hook to defer skeleton display across various pages, enhancing perceived loading performance.
This commit is contained in:
1 parent
d56813d726
commit
4feb8fb629
21 files changed
+135
-28
No files matched your search
@@ -32,5 +32,6 @@ ui/web/pnpm-lock.yaml
|
||||
|
||||
.mcp.json
|
||||
tmp
|
||||
.claude/
|
||||
|
||||
deploy-*.sh
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function CardSkeleton() {
|
||||
@@ -37,3 +38,21 @@ export function DetailSkeleton() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Spinner that only appears after a delay (default 500ms) to avoid flicker on fast loads. */
|
||||
export function DeferredSpinner({ delay = 500 }: { delay?: number }) {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShow(true), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Anti-flicker loading state for skeleton displays.
|
||||
*
|
||||
* Phase 1 (delay): waits `delayMs` before returning true.
|
||||
* If loading finishes within this window, skeleton is never shown.
|
||||
*
|
||||
* Phase 2 (min display): once shown, stays visible for at least `minDisplayMs`.
|
||||
*/
|
||||
export function useDeferredLoading(
|
||||
loading: boolean,
|
||||
delayMs = 200,
|
||||
minDisplayMs = 400,
|
||||
): boolean {
|
||||
const [show, setShow] = useState(false);
|
||||
const delayTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const minTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const shownAt = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
if (!show && !delayTimer.current) {
|
||||
delayTimer.current = setTimeout(() => {
|
||||
delayTimer.current = null;
|
||||
shownAt.current = Date.now();
|
||||
setShow(true);
|
||||
}, delayMs);
|
||||
}
|
||||
} else {
|
||||
if (delayTimer.current) {
|
||||
clearTimeout(delayTimer.current);
|
||||
delayTimer.current = null;
|
||||
}
|
||||
|
||||
if (show && shownAt.current !== null) {
|
||||
const remaining = minDisplayMs - (Date.now() - shownAt.current);
|
||||
if (remaining > 0) {
|
||||
minTimer.current = setTimeout(() => {
|
||||
minTimer.current = null;
|
||||
shownAt.current = null;
|
||||
setShow(false);
|
||||
}, remaining);
|
||||
} else {
|
||||
shownAt.current = null;
|
||||
setShow(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (delayTimer.current) clearTimeout(delayTimer.current);
|
||||
if (minTimer.current) clearTimeout(minTimer.current);
|
||||
};
|
||||
}, [loading, delayMs, minDisplayMs, show]);
|
||||
|
||||
return show;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { AgentGeneralTab } from "./agent-general-tab";
|
||||
import { AgentConfigTab } from "./agent-config-tab";
|
||||
import { AgentFilesTab } from "./agent-files-tab";
|
||||
import { AgentSharesTab } from "./agent-shares-tab";
|
||||
import { DetailSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { DeferredSpinner } from "@/components/shared/loading-skeleton";
|
||||
|
||||
interface AgentDetailPageProps {
|
||||
agentId: string;
|
||||
@@ -41,7 +41,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
<Button variant="ghost" onClick={onBack} className="mb-4 gap-1">
|
||||
<ArrowLeft className="h-4 w-4" /> Back
|
||||
</Button>
|
||||
<DetailSkeleton />
|
||||
<DeferredSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { SearchInput } from "@/components/shared/search-input";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { CardSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { useAgents } from "./hooks/use-agents";
|
||||
@@ -18,6 +19,7 @@ export function AgentsPage() {
|
||||
const { id: detailId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { agents, loading, createAgent, deleteAgent, refresh } = useAgents();
|
||||
const showSkeleton = useDeferredLoading(loading && agents.length === 0);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -67,7 +69,7 @@ export function AgentsPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{loading && agents.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<CardSkeleton key={i} />
|
||||
|
||||
@@ -9,10 +9,12 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import { useApprovals, type PendingApproval } from "./hooks/use-approvals";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
|
||||
export function ApprovalsPage() {
|
||||
const { pending, loading, refresh, approve, deny } = useApprovals();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && pending.length === 0);
|
||||
const [denyTarget, setDenyTarget] = useState<PendingApproval | null>(null);
|
||||
const [approveTarget, setApproveTarget] = useState<{ approval: PendingApproval; always: boolean } | null>(null);
|
||||
|
||||
@@ -34,7 +36,7 @@ export function ApprovalsPage() {
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading && pending.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={3} />
|
||||
) : pending.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ChannelInstanceFormDialog } from "./channel-instance-form-dialog";
|
||||
import { ChannelsStatusView, channelTypeLabels } from "./channels-status-view";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { usePagination } from "@/hooks/use-pagination";
|
||||
|
||||
export function ChannelsPage() {
|
||||
@@ -26,6 +27,7 @@ export function ChannelsPage() {
|
||||
|
||||
const loading = statusLoading || instancesLoading;
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && instances.length === 0);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
@@ -110,7 +112,7 @@ export function ChannelsPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading && instances.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={5} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { CardSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import type { ChannelStatus } from "./hooks/use-channels";
|
||||
|
||||
const channelTypeLabels: Record<string, string> = {
|
||||
@@ -25,6 +26,7 @@ interface ChannelsStatusViewProps {
|
||||
|
||||
export function ChannelsStatusView({ channels, loading, spinning, refresh }: ChannelsStatusViewProps) {
|
||||
const entries = Object.entries(channels);
|
||||
const showSkeleton = useDeferredLoading(loading && entries.length === 0);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -39,7 +41,7 @@ export function ChannelsStatusView({ channels, loading, spinning, refresh }: Cha
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading && entries.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<CardSkeleton key={i} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { DetailSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { useConfig } from "./hooks/use-config";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { ROUTES } from "@/lib/constants";
|
||||
import { GatewaySection } from "./sections/gateway-section";
|
||||
import { ProvidersSection } from "./sections/providers-section";
|
||||
@@ -26,6 +27,7 @@ import { BindingsSection } from "./sections/bindings-section";
|
||||
export function ConfigPage() {
|
||||
const { config, hash, configPath, loading, saving, error, refresh, applyRaw, patch } = useConfig();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && !config);
|
||||
const [rawText, setRawText] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
@@ -49,7 +51,7 @@ export function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !config) {
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader title="Config" description="Gateway configuration" />
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useCron, type CronJob, type CronRunLogEntry } from "./hooks/use-cron";
|
||||
import { CronFormDialog } from "./cron-form-dialog";
|
||||
import { CronRunLogDialog } from "./cron-run-log-dialog";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { usePagination } from "@/hooks/use-pagination";
|
||||
|
||||
function formatSchedule(job: CronJob): string {
|
||||
@@ -30,6 +31,7 @@ function formatSchedule(job: CronJob): string {
|
||||
export function CronPage() {
|
||||
const { jobs, loading, refresh, createJob, toggleJob, deleteJob, runJob, getRunLog } = useCron();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && jobs.length === 0);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CronJob | null>(null);
|
||||
const [runLogTarget, setRunLogTarget] = useState<CronJob | null>(null);
|
||||
@@ -68,7 +70,7 @@ export function CronPage() {
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading && jobs.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={5} />
|
||||
) : jobs.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -11,11 +11,13 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { useCustomTools, type CustomToolData, type CustomToolInput } from "./hooks/use-custom-tools";
|
||||
import { CustomToolFormDialog } from "./custom-tool-form-dialog";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { usePagination } from "@/hooks/use-pagination";
|
||||
|
||||
export function CustomToolsPage() {
|
||||
const { tools, loading, refresh, createTool, updateTool, deleteTool } = useCustomTools();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && tools.length === 0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editTool, setEditTool] = useState<CustomToolData | null>(null);
|
||||
@@ -79,7 +81,7 @@ export function CustomToolsPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading && tools.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={5} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useMCP, type MCPServerData, type MCPServerInput } from "./hooks/use-mcp
|
||||
import { MCPFormDialog } from "./mcp-form-dialog";
|
||||
import { MCPGrantsDialog } from "./mcp-grants-dialog";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { usePagination } from "@/hooks/use-pagination";
|
||||
|
||||
const transportBadge: Record<string, string> = {
|
||||
@@ -23,6 +24,7 @@ const transportBadge: Record<string, string> = {
|
||||
export function MCPPage() {
|
||||
const { servers, loading, refresh, createServer, updateServer, deleteServer, grantAgent, revokeAgent, listGrantsByAgent } = useMCP();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && servers.length === 0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editServer, setEditServer] = useState<MCPServerData | null>(null);
|
||||
@@ -87,7 +89,7 @@ export function MCPPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading && servers.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={5} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -13,15 +13,16 @@ import {
|
||||
type PairedDevice,
|
||||
} from "./hooks/use-nodes";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
|
||||
export function NodesPage() {
|
||||
const { pendingPairings, pairedDevices, loading, refresh, approvePairing, revokePairing } = useNodes();
|
||||
const spinning = useMinLoading(loading);
|
||||
const isEmpty = pendingPairings.length === 0 && pairedDevices.length === 0;
|
||||
const showSkeleton = useDeferredLoading(loading && isEmpty);
|
||||
const [revokeTarget, setRevokeTarget] = useState<PairedDevice | null>(null);
|
||||
const [approveTarget, setApproveTarget] = useState<PendingPairing | null>(null);
|
||||
|
||||
const isEmpty = pendingPairings.length === 0 && pairedDevices.length === 0;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
@@ -35,7 +36,7 @@ export function NodesPage() {
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading && isEmpty ? (
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={4} />
|
||||
) : isEmpty ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { useProviders, type ProviderData, type ProviderInput } from "./hooks/use-providers";
|
||||
import { ProviderFormDialog } from "./provider-form-dialog";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { usePagination } from "@/hooks/use-pagination";
|
||||
|
||||
const typeBadge: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
|
||||
@@ -24,6 +25,7 @@ export function ProvidersPage() {
|
||||
createProvider, updateProvider, deleteProvider,
|
||||
} = useProviders();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && providers.length === 0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editProvider, setEditProvider] = useState<ProviderData | null>(null);
|
||||
@@ -87,7 +89,7 @@ export function ProvidersPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading && providers.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={5} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SearchInput } from "@/components/shared/search-input";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { useSessions } from "./hooks/use-sessions";
|
||||
import { SessionDetailPage } from "./session-detail-page";
|
||||
import { parseSessionKey } from "@/lib/session-key";
|
||||
@@ -17,6 +18,7 @@ export function SessionsPage() {
|
||||
const { key: detailKey } = useParams<{ key: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { sessions, total, loading, refresh, preview, deleteSession, resetSession } = useSessions();
|
||||
const showSkeleton = useDeferredLoading(loading && sessions.length === 0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
@@ -65,7 +67,7 @@ export function SessionsPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{loading && sessions.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={8} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -12,11 +12,13 @@ import { useSkills, type SkillInfo } from "./hooks/use-skills";
|
||||
import { SkillDetailDialog } from "./skill-detail-dialog";
|
||||
import { SkillUploadDialog } from "./skill-upload-dialog";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { usePagination } from "@/hooks/use-pagination";
|
||||
|
||||
export function SkillsPage() {
|
||||
const { skills, loading, refresh, getSkill, uploadSkill, deleteSkill } = useSkills();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && skills.length === 0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedSkill, setSelectedSkill] = useState<(SkillInfo & { content: string }) | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
@@ -88,7 +90,7 @@ export function SkillsPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading && skills.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={5} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -14,11 +14,13 @@ import { formatDate, formatDuration, formatTokens, computeDurationMs } from "@/l
|
||||
import { useTraces, type TraceData } from "./hooks/use-traces";
|
||||
import { TraceDetailDialog } from "./trace-detail-dialog";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import type { AgentEventPayload } from "@/types/chat";
|
||||
|
||||
export function TracesPage() {
|
||||
const { traces, total, loading, load, getTrace } = useTraces();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && traces.length === 0);
|
||||
const [agentFilter, setAgentFilter] = useState("");
|
||||
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -105,7 +107,7 @@ export function TracesPage() {
|
||||
</form>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading && traces.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={8} />
|
||||
) : traces.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PageHeader } from "@/components/shared/page-header";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { useTtsConfig, type TtsConfig, type TtsProviderConfig } from "./hooks/use-tts-config";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
|
||||
const PROVIDERS = [
|
||||
{ value: "", label: "None (Disabled)" },
|
||||
@@ -36,6 +37,7 @@ export function TtsPage() {
|
||||
const spinning = useMinLoading(loading);
|
||||
|
||||
const [draft, setDraft] = useState<TtsConfig>(tts);
|
||||
const showSkeleton = useDeferredLoading(loading && !draft.provider);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,7 +60,7 @@ export function TtsPage() {
|
||||
setDirty(false);
|
||||
};
|
||||
|
||||
if (loading && !draft.provider) {
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader title="Text-to-Speech" description="Configure TTS providers and auto-apply settings" />
|
||||
|
||||
@@ -9,10 +9,12 @@ import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { formatTokens } from "@/lib/format";
|
||||
import { useUsage } from "./hooks/use-usage";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
|
||||
export function UsagePage() {
|
||||
const { records, total, summary, loading, loadRecords, loadSummary } = useUsage();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && records.length === 0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
@@ -45,7 +47,7 @@ export function UsagePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{loading && records.length === 0 ? (
|
||||
{showSkeleton ? (
|
||||
<div className="mt-6">
|
||||
<TableSkeleton rows={4} />
|
||||
</div>
|
||||
|
||||
@@ -85,16 +85,16 @@ export function AppRoutes() {
|
||||
<Route path={ROUTES.OVERVIEW} element={<OverviewPage />} />
|
||||
<Route path={ROUTES.CHAT} element={<ChatPage />} />
|
||||
<Route path={ROUTES.CHAT_SESSION} element={<ChatPage />} />
|
||||
<Route path={ROUTES.AGENTS} element={<AgentsPage />} />
|
||||
<Route path={ROUTES.AGENT_DETAIL} element={<AgentsPage />} />
|
||||
<Route path={ROUTES.SESSIONS} element={<SessionsPage />} />
|
||||
<Route path={ROUTES.SESSION_DETAIL} element={<SessionsPage />} />
|
||||
<Route path={ROUTES.SKILLS} element={<SkillsPage />} />
|
||||
<Route path={ROUTES.SKILL_DETAIL} element={<SkillsPage />} />
|
||||
<Route path={ROUTES.AGENTS} element={<AgentsPage key="list" />} />
|
||||
<Route path={ROUTES.AGENT_DETAIL} element={<AgentsPage key="detail" />} />
|
||||
<Route path={ROUTES.SESSIONS} element={<SessionsPage key="list" />} />
|
||||
<Route path={ROUTES.SESSION_DETAIL} element={<SessionsPage key="detail" />} />
|
||||
<Route path={ROUTES.SKILLS} element={<SkillsPage key="list" />} />
|
||||
<Route path={ROUTES.SKILL_DETAIL} element={<SkillsPage key="detail" />} />
|
||||
<Route path={ROUTES.CRON} element={<CronPage />} />
|
||||
<Route path={ROUTES.CONFIG} element={<ConfigPage />} />
|
||||
<Route path={ROUTES.TRACES} element={<TracesPage />} />
|
||||
<Route path={ROUTES.TRACE_DETAIL} element={<TracesPage />} />
|
||||
<Route path={ROUTES.TRACES} element={<TracesPage key="list" />} />
|
||||
<Route path={ROUTES.TRACE_DETAIL} element={<TracesPage key="detail" />} />
|
||||
<Route path={ROUTES.USAGE} element={<UsagePage />} />
|
||||
<Route path={ROUTES.CHANNELS} element={<ChannelsPage />} />
|
||||
<Route path={ROUTES.APPROVALS} element={<ApprovalsPage />} />
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/errors.ts","./src/api/http-client.ts","./src/api/protocol.ts","./src/api/ws-client.ts","./src/components/chat/agent-selector.tsx","./src/components/chat/chat-input.tsx","./src/components/chat/message-bubble.tsx","./src/components/chat/message-content.tsx","./src/components/chat/rich-content.tsx","./src/components/chat/session-switcher.tsx","./src/components/chat/streaming-text.tsx","./src/components/chat/thinking-indicator.tsx","./src/components/chat/tool-call-card.tsx","./src/components/layout/app-layout.tsx","./src/components/layout/connection-status.tsx","./src/components/layout/sidebar-group.tsx","./src/components/layout/sidebar-item.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/providers/app-providers.tsx","./src/components/providers/theme-provider.tsx","./src/components/providers/ws-provider.tsx","./src/components/shared/confirm-dialog.tsx","./src/components/shared/empty-state.tsx","./src/components/shared/loading-skeleton.tsx","./src/components/shared/markdown-renderer.tsx","./src/components/shared/page-header.tsx","./src/components/shared/pagination.tsx","./src/components/shared/require-auth.tsx","./src/components/shared/search-input.tsx","./src/components/shared/status-badge.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-auto-scroll.ts","./src/hooks/use-clipboard.ts","./src/hooks/use-debounce.ts","./src/hooks/use-debounced-callback.ts","./src/hooks/use-media-query.ts","./src/hooks/use-min-loading.ts","./src/hooks/use-pagination.ts","./src/hooks/use-ws-call.ts","./src/hooks/use-ws-event.ts","./src/hooks/use-ws.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/session-key.ts","./src/lib/slug.ts","./src/lib/utils.ts","./src/pages/agents/agent-card.tsx","./src/pages/agents/agent-create-dialog.tsx","./src/pages/agents/agents-page.tsx","./src/pages/agents/agent-detail/agent-config-tab.tsx","./src/pages/agents/agent-detail/agent-detail-page.tsx","./src/pages/agents/agent-detail/agent-files-tab.tsx","./src/pages/agents/agent-detail/agent-general-tab.tsx","./src/pages/agents/agent-detail/agent-shares-tab.tsx","./src/pages/agents/agent-detail/config-sections/compaction-section.tsx","./src/pages/agents/agent-detail/config-sections/config-section.tsx","./src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx","./src/pages/agents/agent-detail/config-sections/index.ts","./src/pages/agents/agent-detail/config-sections/memory-section.tsx","./src/pages/agents/agent-detail/config-sections/other-config-section.tsx","./src/pages/agents/agent-detail/config-sections/sandbox-section.tsx","./src/pages/agents/agent-detail/config-sections/subagents-section.tsx","./src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx","./src/pages/agents/hooks/use-agent-detail.ts","./src/pages/agents/hooks/use-agents.ts","./src/pages/approvals/approvals-page.tsx","./src/pages/approvals/hooks/use-approvals.ts","./src/pages/channels/channel-fields.tsx","./src/pages/channels/channel-instance-form-dialog.tsx","./src/pages/channels/channel-schemas.ts","./src/pages/channels/channels-page.tsx","./src/pages/channels/channels-status-view.tsx","./src/pages/channels/hooks/use-channel-instances.ts","./src/pages/channels/hooks/use-channels.ts","./src/pages/chat/chat-page.tsx","./src/pages/chat/chat-sidebar.tsx","./src/pages/chat/chat-thread.tsx","./src/pages/chat/hooks/use-chat-messages.ts","./src/pages/chat/hooks/use-chat-send.ts","./src/pages/chat/hooks/use-chat-sessions.ts","./src/pages/config/config-page.tsx","./src/pages/config/hooks/use-config.ts","./src/pages/config/sections/agents-defaults-section.tsx","./src/pages/config/sections/bindings-section.tsx","./src/pages/config/sections/channels-section.tsx","./src/pages/config/sections/cron-section.tsx","./src/pages/config/sections/gateway-section.tsx","./src/pages/config/sections/providers-section.tsx","./src/pages/config/sections/sessions-section.tsx","./src/pages/config/sections/telemetry-section.tsx","./src/pages/config/sections/tools-section.tsx","./src/pages/config/sections/tts-section.tsx","./src/pages/cron/cron-form-dialog.tsx","./src/pages/cron/cron-page.tsx","./src/pages/cron/cron-run-log-dialog.tsx","./src/pages/cron/hooks/use-cron.ts","./src/pages/custom-tools/custom-tool-form-dialog.tsx","./src/pages/custom-tools/custom-tools-page.tsx","./src/pages/custom-tools/hooks/use-custom-tools.ts","./src/pages/login/login-layout.tsx","./src/Line truncated
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/errors.ts","./src/api/http-client.ts","./src/api/protocol.ts","./src/api/ws-client.ts","./src/components/chat/agent-selector.tsx","./src/components/chat/chat-input.tsx","./src/components/chat/message-bubble.tsx","./src/components/chat/message-content.tsx","./src/components/chat/rich-content.tsx","./src/components/chat/session-switcher.tsx","./src/components/chat/streaming-text.tsx","./src/components/chat/thinking-indicator.tsx","./src/components/chat/tool-call-card.tsx","./src/components/layout/app-layout.tsx","./src/components/layout/connection-status.tsx","./src/components/layout/sidebar-group.tsx","./src/components/layout/sidebar-item.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/providers/app-providers.tsx","./src/components/providers/theme-provider.tsx","./src/components/providers/ws-provider.tsx","./src/components/shared/confirm-dialog.tsx","./src/components/shared/empty-state.tsx","./src/components/shared/loading-skeleton.tsx","./src/components/shared/markdown-renderer.tsx","./src/components/shared/page-header.tsx","./src/components/shared/pagination.tsx","./src/components/shared/require-auth.tsx","./src/components/shared/search-input.tsx","./src/components/shared/status-badge.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-auto-scroll.ts","./src/hooks/use-clipboard.ts","./src/hooks/use-debounce.ts","./src/hooks/use-debounced-callback.ts","./src/hooks/use-deferred-loading.ts","./src/hooks/use-media-query.ts","./src/hooks/use-min-loading.ts","./src/hooks/use-pagination.ts","./src/hooks/use-ws-call.ts","./src/hooks/use-ws-event.ts","./src/hooks/use-ws.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/session-key.ts","./src/lib/slug.ts","./src/lib/utils.ts","./src/pages/agents/agent-card.tsx","./src/pages/agents/agent-create-dialog.tsx","./src/pages/agents/agents-page.tsx","./src/pages/agents/agent-detail/agent-config-tab.tsx","./src/pages/agents/agent-detail/agent-detail-page.tsx","./src/pages/agents/agent-detail/agent-files-tab.tsx","./src/pages/agents/agent-detail/agent-general-tab.tsx","./src/pages/agents/agent-detail/agent-shares-tab.tsx","./src/pages/agents/agent-detail/config-sections/compaction-section.tsx","./src/pages/agents/agent-detail/config-sections/config-section.tsx","./src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx","./src/pages/agents/agent-detail/config-sections/index.ts","./src/pages/agents/agent-detail/config-sections/memory-section.tsx","./src/pages/agents/agent-detail/config-sections/other-config-section.tsx","./src/pages/agents/agent-detail/config-sections/sandbox-section.tsx","./src/pages/agents/agent-detail/config-sections/subagents-section.tsx","./src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx","./src/pages/agents/hooks/use-agent-detail.ts","./src/pages/agents/hooks/use-agents.ts","./src/pages/approvals/approvals-page.tsx","./src/pages/approvals/hooks/use-approvals.ts","./src/pages/channels/channel-fields.tsx","./src/pages/channels/channel-instance-form-dialog.tsx","./src/pages/channels/channel-schemas.ts","./src/pages/channels/channels-page.tsx","./src/pages/channels/channels-status-view.tsx","./src/pages/channels/hooks/use-channel-instances.ts","./src/pages/channels/hooks/use-channels.ts","./src/pages/chat/chat-page.tsx","./src/pages/chat/chat-sidebar.tsx","./src/pages/chat/chat-thread.tsx","./src/pages/chat/hooks/use-chat-messages.ts","./src/pages/chat/hooks/use-chat-send.ts","./src/pages/chat/hooks/use-chat-sessions.ts","./src/pages/config/config-page.tsx","./src/pages/config/hooks/use-config.ts","./src/pages/config/sections/agents-defaults-section.tsx","./src/pages/config/sections/bindings-section.tsx","./src/pages/config/sections/channels-section.tsx","./src/pages/config/sections/cron-section.tsx","./src/pages/config/sections/gateway-section.tsx","./src/pages/config/sections/providers-section.tsx","./src/pages/config/sections/sessions-section.tsx","./src/pages/config/sections/telemetry-section.tsx","./src/pages/config/sections/tools-section.tsx","./src/pages/config/sections/tts-section.tsx","./src/pages/cron/cron-form-dialog.tsx","./src/pages/cron/cron-page.tsx","./src/pages/cron/cron-run-log-dialog.tsx","./src/pages/cron/hooks/use-cron.ts","./src/pages/custom-tools/custom-tool-form-dialog.tsx","./src/pages/custom-tools/custom-tools-page.tsx","./src/pages/custom-tools/hooks/use-custom-tools.ts","./srcLine truncated
|
||||
Reference in new issue
Block a user