feat: Implement useDeferredLoading hook to defer skeleton display across various pages, enhancing perceived loading performance.

This commit is contained in:
viettranx committed 2026-02-23 17:52:48 +07:00
1 parent d56813d726
commit 4feb8fb629
21 files changed
+135 -28

No files matched your search

+1
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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} />
+3 -1
View File
@@ -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" />
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+4 -3
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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" />
+3 -1
View File
@@ -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>
+8 -8
View File
@@ -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
View File
@@ -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