perf: speed up initial chat, channels, skills, and cron loading (#901)
This commit is contained in:
@@ -137,9 +137,11 @@ export function Sidebar() {
|
||||
let cancelled = false;
|
||||
const hasExistingMessages = useChatStore.getState().messages.length > 0;
|
||||
(async () => {
|
||||
await loadSessions();
|
||||
await Promise.allSettled([
|
||||
loadSessions(),
|
||||
loadHistory(hasExistingMessages),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
await loadHistory(hasExistingMessages);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -98,6 +98,12 @@ interface DeleteTarget {
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
type FetchPageDataOptions = {
|
||||
probe?: boolean;
|
||||
configOnly?: boolean;
|
||||
forceAgentsRefresh?: boolean;
|
||||
};
|
||||
|
||||
function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget): ChannelGroupItem[] {
|
||||
if (target.accountId) {
|
||||
return groups
|
||||
@@ -143,7 +149,9 @@ export function Channels() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
|
||||
const convergenceRefreshTimersRef = useRef<number[]>([]);
|
||||
const fetchInFlightRef = useRef(false);
|
||||
const queuedFetchOptionsRef = useRef<{ probe?: boolean } | null>(null);
|
||||
const queuedFetchOptionsRef = useRef<FetchPageDataOptions | null>(null);
|
||||
const agentsFetchInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const hasLoadedAgentsRef = useRef(false);
|
||||
|
||||
const displayedChannelTypes = getPrimaryChannels();
|
||||
const visibleChannelGroups = channelGroups;
|
||||
@@ -159,16 +167,46 @@ export function Channels() {
|
||||
const agentsRef = useRef(agents);
|
||||
agentsRef.current = agents;
|
||||
|
||||
const ensureAgentsLoaded = useCallback(async () => {
|
||||
if (hasLoadedAgentsRef.current) return;
|
||||
if (agentsFetchInFlightRef.current) {
|
||||
await agentsFetchInFlightRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
agentsFetchInFlightRef.current = (async () => {
|
||||
try {
|
||||
const agentsRes = await hostApiFetch<{ success: boolean; agents?: AgentItem[]; error?: string }>('/api/agents');
|
||||
if (!agentsRes.success) {
|
||||
throw new Error(agentsRes.error || 'Failed to load agents');
|
||||
}
|
||||
setAgents(agentsRes.agents || []);
|
||||
hasLoadedAgentsRef.current = true;
|
||||
} catch (agentsError) {
|
||||
console.warn(`[channels-ui] load agents failed error=${String(agentsError)}`);
|
||||
} finally {
|
||||
agentsFetchInFlightRef.current = null;
|
||||
}
|
||||
})();
|
||||
|
||||
await agentsFetchInFlightRef.current;
|
||||
}, []);
|
||||
|
||||
const mergeFetchOptions = (
|
||||
base: { probe?: boolean } | null,
|
||||
incoming: { probe?: boolean } | undefined,
|
||||
): { probe?: boolean } => {
|
||||
base: FetchPageDataOptions | null,
|
||||
incoming: FetchPageDataOptions | undefined,
|
||||
): FetchPageDataOptions => {
|
||||
if (!base) return incoming ?? {};
|
||||
if (!incoming) return base;
|
||||
return {
|
||||
probe: Boolean(base?.probe) || Boolean(incoming?.probe),
|
||||
// If either request needs runtime data, do not keep config-only mode.
|
||||
configOnly: Boolean(base?.configOnly) && Boolean(incoming?.configOnly),
|
||||
forceAgentsRefresh: Boolean(base?.forceAgentsRefresh) || Boolean(incoming?.forceAgentsRefresh),
|
||||
};
|
||||
};
|
||||
|
||||
const fetchPageData = useCallback(async (options?: { probe?: boolean }) => {
|
||||
const fetchPageData = useCallback(async (options?: FetchPageDataOptions) => {
|
||||
if (fetchInFlightRef.current) {
|
||||
queuedFetchOptionsRef.current = mergeFetchOptions(queuedFetchOptionsRef.current, options);
|
||||
return;
|
||||
@@ -176,20 +214,27 @@ export function Channels() {
|
||||
fetchInFlightRef.current = true;
|
||||
const startedAt = Date.now();
|
||||
const probe = options?.probe === true;
|
||||
console.info(`[channels-ui] fetch start probe=${probe ? '1' : '0'}`);
|
||||
const configOnly = options?.configOnly === true;
|
||||
console.info(`[channels-ui] fetch start mode=${configOnly ? 'config' : 'runtime'} probe=${probe ? '1' : '0'}`);
|
||||
// Only show loading spinner on first load (stale-while-revalidate).
|
||||
const hasData = channelGroupsRef.current.length > 0 || agentsRef.current.length > 0;
|
||||
if (!hasData) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
if (options?.forceAgentsRefresh) {
|
||||
hasLoadedAgentsRef.current = false;
|
||||
}
|
||||
void ensureAgentsLoaded();
|
||||
try {
|
||||
const [channelsRes, agentsRes] = await Promise.all([
|
||||
hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>(
|
||||
options?.probe ? '/api/channels/accounts?probe=1' : '/api/channels/accounts'
|
||||
),
|
||||
hostApiFetch<{ success: boolean; agents?: AgentItem[]; error?: string }>('/api/agents'),
|
||||
]);
|
||||
const channelsPath = configOnly
|
||||
? '/api/channels/accounts?mode=config'
|
||||
: options?.probe
|
||||
? '/api/channels/accounts?probe=1'
|
||||
: '/api/channels/accounts';
|
||||
const channelsRes = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>(
|
||||
channelsPath
|
||||
);
|
||||
|
||||
type ChannelsResponse = {
|
||||
success: boolean;
|
||||
@@ -203,23 +248,18 @@ export function Channels() {
|
||||
throw new Error(channelsPayload.error || 'Failed to load channels');
|
||||
}
|
||||
|
||||
if (!agentsRes.success) {
|
||||
throw new Error(agentsRes.error || 'Failed to load agents');
|
||||
}
|
||||
|
||||
setChannelGroups(channelsPayload.channels || []);
|
||||
setAgents(agentsRes.agents || []);
|
||||
setGatewayHealth(channelsPayload.gatewayHealth || DEFAULT_GATEWAY_HEALTH);
|
||||
setDiagnosticsSnapshot(null);
|
||||
setShowDiagnostics(false);
|
||||
console.info(
|
||||
`[channels-ui] fetch ok probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${(channelsPayload.channels || []).map((item) => `${item.channelType}:${item.status}`).join(',')}`
|
||||
`[channels-ui] fetch ok mode=${configOnly ? 'config' : 'runtime'} probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${(channelsPayload.channels || []).map((item) => `${item.channelType}:${item.status}`).join(',')}`
|
||||
);
|
||||
} catch (fetchError) {
|
||||
// Preserve previous data on error — don't clear channelGroups/agents.
|
||||
setError(String(fetchError));
|
||||
console.warn(
|
||||
`[channels-ui] fetch fail probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} error=${String(fetchError)}`
|
||||
`[channels-ui] fetch fail mode=${configOnly ? 'config' : 'runtime'} probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} error=${String(fetchError)}`
|
||||
);
|
||||
} finally {
|
||||
fetchInFlightRef.current = false;
|
||||
@@ -232,7 +272,7 @@ export function Channels() {
|
||||
}
|
||||
// Stable reference — reads state via refs, no deps needed.
|
||||
|
||||
}, []);
|
||||
}, [ensureAgentsLoaded]);
|
||||
|
||||
const clearConvergenceRefreshTimers = useCallback(() => {
|
||||
convergenceRefreshTimersRef.current.forEach((timerId) => {
|
||||
@@ -261,6 +301,7 @@ export function Channels() {
|
||||
}, [clearConvergenceRefreshTimers, fetchPageData]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPageData({ configOnly: true });
|
||||
void fetchPageData();
|
||||
}, [fetchPageData]);
|
||||
|
||||
@@ -329,7 +370,7 @@ export function Channels() {
|
||||
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
|
||||
|
||||
const handleRefresh = () => {
|
||||
void fetchPageData({ probe: true });
|
||||
void fetchPageData({ probe: true, forceAgentsRefresh: true });
|
||||
};
|
||||
|
||||
const fetchDiagnosticsSnapshot = useCallback(async (): Promise<GatewayDiagnosticSnapshot> => {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { hostApiFetch } from '@/lib/host-api';
|
||||
import { useChatStore } from './chat';
|
||||
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
|
||||
|
||||
let _fetchJobsInFlight: Promise<void> | null = null;
|
||||
|
||||
interface CronState {
|
||||
jobs: CronJob[];
|
||||
loading: boolean;
|
||||
@@ -28,28 +30,41 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
error: null,
|
||||
|
||||
fetchJobs: async () => {
|
||||
const currentJobs = useCronStore.getState().jobs;
|
||||
// Only show loading spinner when there's no data yet (stale-while-revalidate).
|
||||
if (currentJobs.length === 0) {
|
||||
set({ loading: true, error: null });
|
||||
} else {
|
||||
set({ error: null });
|
||||
if (_fetchJobsInFlight) {
|
||||
await _fetchJobsInFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
_fetchJobsInFlight = (async () => {
|
||||
const currentJobs = useCronStore.getState().jobs;
|
||||
// Only show loading spinner when there's no data yet (stale-while-revalidate).
|
||||
if (currentJobs.length === 0) {
|
||||
set({ loading: true, error: null });
|
||||
} else {
|
||||
set({ error: null });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await hostApiFetch<CronJob[]>('/api/cron/jobs');
|
||||
|
||||
// Gateway now correctly returns agentId for all jobs.
|
||||
// If Gateway returned fewer jobs than we have (e.g. race condition), preserve
|
||||
// the extra ones from current state to avoid losing data.
|
||||
const resultIds = new Set(result.map((j) => j.id));
|
||||
const extraJobs = currentJobs.filter((j) => !resultIds.has(j.id));
|
||||
const allJobs = [...result, ...extraJobs];
|
||||
|
||||
set({ jobs: allJobs, loading: false });
|
||||
} catch (error) {
|
||||
// Preserve previous jobs on error so the user sees stale data instead of nothing.
|
||||
set({ error: String(error), loading: false });
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
const result = await hostApiFetch<CronJob[]>('/api/cron/jobs');
|
||||
|
||||
// Gateway now correctly returns agentId for all jobs.
|
||||
// If Gateway returned fewer jobs than we have (e.g. race condition), preserve
|
||||
// the extra ones from current state to avoid losing data.
|
||||
const resultIds = new Set(result.map((j) => j.id));
|
||||
const extraJobs = currentJobs.filter((j) => !resultIds.has(j.id));
|
||||
const allJobs = [...result, ...extraJobs];
|
||||
|
||||
set({ jobs: allJobs, loading: false });
|
||||
} catch (error) {
|
||||
// Preserve previous jobs on error so the user sees stale data instead of nothing.
|
||||
set({ error: String(error), loading: false });
|
||||
await _fetchJobsInFlight;
|
||||
} finally {
|
||||
_fetchJobsInFlight = null;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -92,14 +92,15 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
set({ loading: true, error: null });
|
||||
}
|
||||
try {
|
||||
// 1. Fetch from Gateway (running skills)
|
||||
const gatewayData = await useGatewayStore.getState().rpc<GatewaySkillsStatusResult>('skills.status');
|
||||
|
||||
// 2. Fetch from ClawHub (installed on disk)
|
||||
const clawhubResult = await hostApiFetch<{ success: boolean; results?: ClawHubListResult[]; error?: string }>('/api/clawhub/list');
|
||||
|
||||
// 3. Fetch configurations directly from Electron (since Gateway doesn't return them)
|
||||
const configResult = await hostApiFetch<Record<string, { apiKey?: string; env?: Record<string, string> }>>('/api/skills/configs');
|
||||
// Fetch all skill sources in parallel to reduce first-load latency.
|
||||
const gatewayDataPromise = useGatewayStore.getState().rpc<GatewaySkillsStatusResult>('skills.status');
|
||||
const clawhubResultPromise = hostApiFetch<{ success: boolean; results?: ClawHubListResult[]; error?: string }>('/api/clawhub/list');
|
||||
const configResultPromise = hostApiFetch<Record<string, { apiKey?: string; env?: Record<string, string> }>>('/api/skills/configs');
|
||||
const [gatewayData, clawhubResult, configResult] = await Promise.all([
|
||||
gatewayDataPromise,
|
||||
clawhubResultPromise,
|
||||
configResultPromise,
|
||||
]);
|
||||
|
||||
let combinedSkills: Skill[] = [];
|
||||
const currentSkills = get().skills;
|
||||
|
||||
Reference in New Issue
Block a user