mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 02:14:43 +00:00
fix(ui): eliminate loading flicker on page refresh
Root cause: ws object is a stable ref — useCallback deps never changed when WS connected, so useEffect never re-fired to fetch data. Fix: subscribe to reactive `connected` state from auth store in all data-loading hooks. For manual hooks, add connected to useCallback deps and initialize loading=true. For TanStack Query hooks, add enabled: connected and use isPending (true while disabled with no cache). Detail pages now use DetailPageSkeleton matching final layout shape instead of DeferredSpinner to prevent layout shift.
This commit is contained in:
1 parent
2bebe76439
commit
d850467175
16 files changed
+92
-44
No files matched your search
@@ -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 (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-6 flex items-start gap-4">
|
||||
<Skeleton className="h-9 w-9 shrink-0 rounded-md" />
|
||||
<Skeleton className="h-12 w-12 shrink-0 rounded-xl" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-4xl rounded-xl border bg-card p-3 shadow-sm sm:p-4">
|
||||
<div className="flex gap-2 border-b pb-2">
|
||||
{Array.from({ length: tabs }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-20 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<AgentLinkData[]>([]);
|
||||
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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<PendingApproval[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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();
|
||||
|
||||
@@ -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 (
|
||||
<div className="p-4 sm:p-6">
|
||||
<Button variant="ghost" onClick={onBack} className="mb-4 gap-1">
|
||||
<ArrowLeft className="h-4 w-4" /> Back
|
||||
</Button>
|
||||
<DeferredSpinner />
|
||||
</div>
|
||||
);
|
||||
return <DetailPageSkeleton tabs={4} />;
|
||||
}
|
||||
|
||||
const status = channels[instance.name] ?? null;
|
||||
|
||||
@@ -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<Record<string, ChannelStatus>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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();
|
||||
|
||||
@@ -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<SessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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();
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const hashRef = useRef("");
|
||||
|
||||
const { data, isLoading: loading } = useQuery({
|
||||
const { data, isPending: loading } = useQuery({
|
||||
queryKey: queryKeys.config.all,
|
||||
queryFn: async (): Promise<ConfigData> => {
|
||||
if (!ws.isConnected) return { config: {}, hash: "", path: "" };
|
||||
const res = await ws.call<ConfigData>(Methods.CONFIG_GET);
|
||||
hashRef.current = res.hash;
|
||||
return res;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: connected,
|
||||
});
|
||||
|
||||
const config = data?.config ?? null;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<PendingPairing[]>([]);
|
||||
const [pairedDevices, setPairedDevices] = useState<PairedDevice[]>([]);
|
||||
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();
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<TeamData[]>([]);
|
||||
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: {
|
||||
|
||||
@@ -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<string | null>(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<string, unknown> }>(Methods.CONFIG_GET);
|
||||
const ttsConfig = (res.config?.tts as TtsConfig) ?? DEFAULT_TTS;
|
||||
return { ...DEFAULT_TTS, ...ttsConfig };
|
||||
},
|
||||
enabled: connected,
|
||||
});
|
||||
|
||||
const invalidate = useCallback(
|
||||
|
||||
@@ -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<UsageRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [summary, setSummary] = useState<UsageSummary | null>(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<UsageSummary>(Methods.USAGE_SUMMARY);
|
||||
setSummary(res);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [ws]);
|
||||
}, [ws, connected]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
|
||||
Reference in new issue
Block a user