diff --git a/ui/web/src/components/shared/loading-skeleton.tsx b/ui/web/src/components/shared/loading-skeleton.tsx index ed827831..32102d94 100644 --- a/ui/web/src/components/shared/loading-skeleton.tsx +++ b/ui/web/src/components/shared/loading-skeleton.tsx @@ -39,6 +39,35 @@ export function DetailSkeleton() { ); } +/** Skeleton matching detail page layout (header + tabs card) to prevent layout shift on load. */ +export function DetailPageSkeleton({ tabs = 4 }: { tabs?: number }) { + return ( +
+
+ + +
+ + +
+
+
+
+ {Array.from({ length: tabs }).map((_, i) => ( + + ))} +
+
+ + + + +
+
+
+ ); +} + /** 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); diff --git a/ui/web/src/hooks/use-pending-pairings-count.ts b/ui/web/src/hooks/use-pending-pairings-count.ts index a1094d0e..474901e7 100644 --- a/ui/web/src/hooks/use-pending-pairings-count.ts +++ b/ui/web/src/hooks/use-pending-pairings-count.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useWs } from "./use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { useWsEvent } from "./use-ws-event"; import { Methods, Events } from "@/api/protocol"; import { toast } from "@/stores/use-toast-store"; @@ -11,10 +12,11 @@ interface Options { export function usePendingPairingsCount({ showToast }: Options = {}) { const ws = useWs(); + const connected = useAuthStore((s) => s.connected); const [pendingCount, setPendingCount] = useState(0); const fetchCount = useCallback(async () => { - if (!ws.isConnected) return; + if (!connected) return; try { const res = await ws.call<{ pending: { code: string }[] }>( Methods.PAIRING_LIST, @@ -23,7 +25,7 @@ export function usePendingPairingsCount({ showToast }: Options = {}) { } catch { // ignore } - }, [ws]); + }, [ws, connected]); useEffect(() => { fetchCount(); diff --git a/ui/web/src/pages/agents/hooks/use-agent-links.ts b/ui/web/src/pages/agents/hooks/use-agent-links.ts index e1f379e6..6d93e36b 100644 --- a/ui/web/src/pages/agents/hooks/use-agent-links.ts +++ b/ui/web/src/pages/agents/hooks/use-agent-links.ts @@ -1,15 +1,17 @@ import { useState, useCallback } from "react"; import { useWs } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; import type { AgentLinkData, AgentLinkSettings } from "@/types/agent"; export function useAgentLinks(agentId: string) { const ws = useWs(); + const connected = useAuthStore((s) => s.connected); const [links, setLinks] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const load = useCallback(async () => { - if (!ws.isConnected) return; + if (!connected) return; setLoading(true); try { const res = await ws.call<{ links: AgentLinkData[]; count: number }>( @@ -22,7 +24,7 @@ export function useAgentLinks(agentId: string) { } finally { setLoading(false); } - }, [ws, agentId]); + }, [ws, agentId, connected]); const createLink = useCallback( async (params: { diff --git a/ui/web/src/pages/agents/hooks/use-agents.ts b/ui/web/src/pages/agents/hooks/use-agents.ts index 6f732da6..42c1df74 100644 --- a/ui/web/src/pages/agents/hooks/use-agents.ts +++ b/ui/web/src/pages/agents/hooks/use-agents.ts @@ -19,7 +19,7 @@ export function useAgents() { const connected = useAuthStore((s) => s.connected); const queryClient = useQueryClient(); - const { data: agents = [], isLoading: loading, error: queryError } = useQuery({ + const { data: agents = [], isPending: loading, error: queryError } = useQuery({ queryKey: queryKeys.agents.all, queryFn: async () => { // Try HTTP first (returns full agent data, filtered by user access) diff --git a/ui/web/src/pages/approvals/hooks/use-approvals.ts b/ui/web/src/pages/approvals/hooks/use-approvals.ts index 66b9e041..5d6824d1 100644 --- a/ui/web/src/pages/approvals/hooks/use-approvals.ts +++ b/ui/web/src/pages/approvals/hooks/use-approvals.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useWs } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { useWsEvent } from "@/hooks/use-ws-event"; import { Methods, Events } from "@/api/protocol"; import { toast } from "@/stores/use-toast-store"; @@ -13,12 +14,13 @@ export interface PendingApproval { export function useApprovals() { const ws = useWs(); + const connected = useAuthStore((s) => s.connected); const [pending, setPending] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const load = useCallback(async () => { - if (!ws.isConnected) return; + if (!connected) return; setLoading(true); setError(null); try { @@ -29,7 +31,7 @@ export function useApprovals() { } finally { setLoading(false); } - }, [ws]); + }, [ws, connected]); useEffect(() => { load(); diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx index 4204ed04..ad73cf5b 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx @@ -10,7 +10,7 @@ import { ChannelCredentialsTab } from "./channel-credentials-tab"; import { ChannelConfigTab } from "./channel-config-tab"; import { ChannelGroupsTab } from "./channel-groups-tab"; import { ChannelWritersTab } from "./channel-writers-tab"; -import { DeferredSpinner } from "@/components/shared/loading-skeleton"; +import { DetailPageSkeleton } from "@/components/shared/loading-skeleton"; import { channelTypeLabels } from "../channels-status-view"; import { useChannels } from "../hooks/use-channels"; @@ -34,14 +34,7 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps const [activeTab, setActiveTab] = useState("general"); if (loading || !instance) { - return ( -
- - -
- ); + return ; } const status = channels[instance.name] ?? null; diff --git a/ui/web/src/pages/channels/hooks/use-channels.ts b/ui/web/src/pages/channels/hooks/use-channels.ts index 30679772..4336b05c 100644 --- a/ui/web/src/pages/channels/hooks/use-channels.ts +++ b/ui/web/src/pages/channels/hooks/use-channels.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useWs } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; export interface ChannelStatus { @@ -9,12 +10,13 @@ export interface ChannelStatus { export function useChannels() { const ws = useWs(); + const connected = useAuthStore((s) => s.connected); const [channels, setChannels] = useState>({}); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const load = useCallback(async () => { - if (!ws.isConnected) return; + if (!connected) return; setLoading(true); setError(null); try { @@ -27,7 +29,7 @@ export function useChannels() { } finally { setLoading(false); } - }, [ws]); + }, [ws, connected]); useEffect(() => { load(); diff --git a/ui/web/src/pages/chat/hooks/use-chat-sessions.ts b/ui/web/src/pages/chat/hooks/use-chat-sessions.ts index b379400e..de7e54a3 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-sessions.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-sessions.ts @@ -11,12 +11,13 @@ import { useAuthStore } from "@/stores/use-auth-store"; export function useChatSessions(agentId: string) { const ws = useWs(); const userId = useAuthStore((s) => s.userId); + const connected = useAuthStore((s) => s.connected); const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const loadSessions = useCallback(async () => { - if (!ws.isConnected) return; + if (!connected) return; setLoading(true); setError(null); try { @@ -34,7 +35,7 @@ export function useChatSessions(agentId: string) { } finally { setLoading(false); } - }, [ws, agentId]); + }, [ws, agentId, connected]); useEffect(() => { loadSessions(); diff --git a/ui/web/src/pages/config/hooks/use-config.ts b/ui/web/src/pages/config/hooks/use-config.ts index e729e69a..2ff35d75 100644 --- a/ui/web/src/pages/config/hooks/use-config.ts +++ b/ui/web/src/pages/config/hooks/use-config.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useWs } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; import { queryKeys } from "@/lib/query-keys"; @@ -12,20 +13,21 @@ interface ConfigData { export function useConfig() { const ws = useWs(); + const connected = useAuthStore((s) => s.connected); const queryClient = useQueryClient(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const hashRef = useRef(""); - const { data, isLoading: loading } = useQuery({ + const { data, isPending: loading } = useQuery({ queryKey: queryKeys.config.all, queryFn: async (): Promise => { - if (!ws.isConnected) return { config: {}, hash: "", path: "" }; const res = await ws.call(Methods.CONFIG_GET); hashRef.current = res.hash; return res; }, staleTime: 5 * 60_000, + enabled: connected, }); const config = data?.config ?? null; diff --git a/ui/web/src/pages/cron/hooks/use-cron.ts b/ui/web/src/pages/cron/hooks/use-cron.ts index f609e4db..5e0069ef 100644 --- a/ui/web/src/pages/cron/hooks/use-cron.ts +++ b/ui/web/src/pages/cron/hooks/use-cron.ts @@ -1,6 +1,7 @@ import { useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useWs } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; import { queryKeys } from "@/lib/query-keys"; import { toast } from "@/stores/use-toast-store"; @@ -49,17 +50,18 @@ export interface CronRunLogEntry { export function useCron() { const ws = useWs(); + const connected = useAuthStore((s) => s.connected); const queryClient = useQueryClient(); - const { data: jobs = [], isLoading: loading } = useQuery({ + const { data: jobs = [], isPending: loading } = useQuery({ queryKey: queryKeys.cron.all, queryFn: async () => { - if (!ws.isConnected) return []; const res = await ws.call<{ jobs: CronJob[] }>(Methods.CRON_LIST, { includeDisabled: true, }); return res.jobs ?? []; }, + enabled: connected, }); const invalidate = useCallback( diff --git a/ui/web/src/pages/nodes/hooks/use-nodes.ts b/ui/web/src/pages/nodes/hooks/use-nodes.ts index 48614ad9..e78e38d8 100644 --- a/ui/web/src/pages/nodes/hooks/use-nodes.ts +++ b/ui/web/src/pages/nodes/hooks/use-nodes.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useWs } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { useWsEvent } from "@/hooks/use-ws-event"; import { Methods, Events } from "@/api/protocol"; @@ -23,12 +24,13 @@ export interface PairedDevice { export function useNodes() { const ws = useWs(); + const connected = useAuthStore((s) => s.connected); const [pendingPairings, setPendingPairings] = useState([]); const [pairedDevices, setPairedDevices] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const load = useCallback(async () => { - if (!ws.isConnected) return; + if (!connected) return; setLoading(true); try { const res = await ws.call<{ @@ -42,7 +44,7 @@ export function useNodes() { } finally { setLoading(false); } - }, [ws]); + }, [ws, connected]); useEffect(() => { load(); diff --git a/ui/web/src/pages/sessions/hooks/use-sessions.ts b/ui/web/src/pages/sessions/hooks/use-sessions.ts index b4a1813b..a0231dba 100644 --- a/ui/web/src/pages/sessions/hooks/use-sessions.ts +++ b/ui/web/src/pages/sessions/hooks/use-sessions.ts @@ -1,6 +1,7 @@ import { useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useWs } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; import { queryKeys } from "@/lib/query-keys"; import type { SessionInfo, SessionPreview, Message } from "@/types/session"; @@ -13,12 +14,13 @@ interface UseSessionsOptions { export function useSessions(opts: UseSessionsOptions = {}) { const ws = useWs(); + const connected = useAuthStore((s) => s.connected); const queryClient = useQueryClient(); const { agentFilter, limit, offset } = opts; const queryKey = queryKeys.sessions.list({ agentFilter, limit, offset }); - const { data, isLoading: loading } = useQuery({ + const { data, isPending: loading } = useQuery({ queryKey, queryFn: async () => { if (!ws.isConnected) return { sessions: [] as SessionInfo[], total: 0 }; @@ -30,6 +32,7 @@ export function useSessions(opts: UseSessionsOptions = {}) { return { sessions: res.sessions ?? [], total: res.total ?? 0 }; }, placeholderData: (prev) => prev, + enabled: connected, }); const sessions = data?.sessions ?? []; diff --git a/ui/web/src/pages/skills/hooks/use-skills.ts b/ui/web/src/pages/skills/hooks/use-skills.ts index 3719c07f..7a7e2913 100644 --- a/ui/web/src/pages/skills/hooks/use-skills.ts +++ b/ui/web/src/pages/skills/hooks/use-skills.ts @@ -1,6 +1,7 @@ import { useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useWs, useHttp } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; import { queryKeys } from "@/lib/query-keys"; import type { SkillInfo } from "@/types/skill"; @@ -10,16 +11,17 @@ export type { SkillInfo }; export function useSkills() { const ws = useWs(); const http = useHttp(); + const connected = useAuthStore((s) => s.connected); const queryClient = useQueryClient(); - const { data: skills = [], isLoading: loading } = useQuery({ + const { data: skills = [], isPending: loading } = useQuery({ queryKey: queryKeys.skills.all, queryFn: async () => { - if (!ws.isConnected) return []; const res = await ws.call<{ skills: SkillInfo[] }>(Methods.SKILLS_LIST); return res.skills ?? []; }, staleTime: 60_000, + enabled: connected, }); const invalidate = useCallback( diff --git a/ui/web/src/pages/teams/hooks/use-teams.ts b/ui/web/src/pages/teams/hooks/use-teams.ts index b4003daf..57520b36 100644 --- a/ui/web/src/pages/teams/hooks/use-teams.ts +++ b/ui/web/src/pages/teams/hooks/use-teams.ts @@ -1,15 +1,17 @@ import { useState, useCallback } from "react"; import { useWs } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; import type { TeamData, TeamMemberData, TeamTaskData, TeamAccessSettings } from "@/types/team"; export function useTeams() { const ws = useWs(); + const connected = useAuthStore((s) => s.connected); const [teams, setTeams] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const load = useCallback(async () => { - if (!ws.isConnected) return; + if (!connected) return; setLoading(true); try { const res = await ws.call<{ teams: TeamData[]; count: number }>( @@ -21,7 +23,7 @@ export function useTeams() { } finally { setLoading(false); } - }, [ws]); + }, [ws, connected]); const createTeam = useCallback( async (params: { diff --git a/ui/web/src/pages/tts/hooks/use-tts-config.ts b/ui/web/src/pages/tts/hooks/use-tts-config.ts index 75373cba..6c532ea4 100644 --- a/ui/web/src/pages/tts/hooks/use-tts-config.ts +++ b/ui/web/src/pages/tts/hooks/use-tts-config.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useWs } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; import { queryKeys } from "@/lib/query-keys"; @@ -43,18 +44,19 @@ const DEFAULT_TTS: TtsConfig = { export function useTtsConfig() { const ws = useWs(); + const connected = useAuthStore((s) => s.connected); const queryClient = useQueryClient(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const { data: tts = DEFAULT_TTS, isLoading: loading } = useQuery({ + const { data: tts = DEFAULT_TTS, isPending: loading } = useQuery({ queryKey: queryKeys.tts.all, queryFn: async () => { - if (!ws.isConnected) return DEFAULT_TTS; const res = await ws.call<{ config: Record }>(Methods.CONFIG_GET); const ttsConfig = (res.config?.tts as TtsConfig) ?? DEFAULT_TTS; return { ...DEFAULT_TTS, ...ttsConfig }; }, + enabled: connected, }); const invalidate = useCallback( diff --git a/ui/web/src/pages/usage/hooks/use-usage.ts b/ui/web/src/pages/usage/hooks/use-usage.ts index d562912f..950b2b06 100644 --- a/ui/web/src/pages/usage/hooks/use-usage.ts +++ b/ui/web/src/pages/usage/hooks/use-usage.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useWs } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; export interface UsageRecord { @@ -28,14 +29,15 @@ export interface UsageSummary { export function useUsage() { const ws = useWs(); + const connected = useAuthStore((s) => s.connected); const [records, setRecords] = useState([]); const [total, setTotal] = useState(0); const [summary, setSummary] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const loadRecords = useCallback( async (opts?: { agentId?: string; limit?: number; offset?: number }) => { - if (!ws.isConnected) return; + if (!connected) return; setLoading(true); try { const res = await ws.call<{ records: UsageRecord[]; total?: number }>(Methods.USAGE_GET, { @@ -51,18 +53,18 @@ export function useUsage() { setLoading(false); } }, - [ws], + [ws, connected], ); const loadSummary = useCallback(async () => { - if (!ws.isConnected) return; + if (!connected) return; try { const res = await ws.call(Methods.USAGE_SUMMARY); setSummary(res); } catch { // ignore } - }, [ws]); + }, [ws, connected]); useEffect(() => { loadRecords();