diff --git a/src/components/ErrorBoundary/index.tsx b/src/components/ErrorBoundary/index.tsx index 561a8d6bb0..04b2c8cd07 100644 --- a/src/components/ErrorBoundary/index.tsx +++ b/src/components/ErrorBoundary/index.tsx @@ -99,7 +99,12 @@ class ErrorBoundary extends Component { render() { if (this.state.hasError) { - return ; + return ( + + ); } return this.props.children; diff --git a/src/engines/ChatPanel/panels/ProjectPanelView.tsx b/src/engines/ChatPanel/panels/ProjectPanelView.tsx index b1c8139192..4209e01f23 100644 --- a/src/engines/ChatPanel/panels/ProjectPanelView.tsx +++ b/src/engines/ChatPanel/panels/ProjectPanelView.tsx @@ -26,6 +26,7 @@ import { usePublishChatPanelHeader } from "@src/engines/ChatPanel/header"; import KanbanBoard from "@src/features/KanbanBoard"; import type { KanbanTask, TaskStatus } from "@src/features/KanbanBoard"; import { allocateCloudAwareWorkItemId } from "@src/features/Org2Cloud/cloudShortId"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { useCurrentUserMemberIds, @@ -76,6 +77,16 @@ interface ProjectPanelViewProps { const PROJECT_PANEL_TABS: ProjectPanelTab[] = ["overview", "list", "kanban"]; +interface ProjectWorkItemsResource { + shortIds: Map; + workItems: WorkItem[]; +} + +const EMPTY_PROJECT_WORK_ITEMS: ProjectWorkItemsResource = { + shortIds: new Map(), + workItems: [], +}; + function getProjectOverviewDescription( project: ChatPanelSelectedProject["project"] ) { @@ -102,12 +113,6 @@ export const ProjectPanelView: React.FC = ({ const [projectBodyLoading, setProjectBodyLoading] = useState(false); const [projectBodyError, setProjectBodyError] = useState(null); const lastSavedDescriptionRef = useRef(sidebarProjectDescription); - const [workItems, setWorkItems] = useState([]); - const [workItemShortIds, setWorkItemShortIds] = useState>( - new Map() - ); - const [workItemsLoading, setWorkItemsLoading] = useState(false); - const [workItemsError, setWorkItemsError] = useState(null); const [projectSyncAdapter, setProjectSyncAdapter] = useState<{ projectSlug: string; adapterId: string | null; @@ -243,34 +248,31 @@ export const ProjectPanelView: React.FC = ({ }; }, [projectSlug, selectedProject.project.id, sidebarProjectDescription]); - const loadProjectWorkItems = useCallback(async () => { - if (!projectSlug) { - setWorkItems([]); - setWorkItemShortIds(new Map()); - return; - } - - setWorkItemsLoading(true); - setWorkItemsError(null); - try { - const viewData = await projectApi.readWorkItemsViewData(projectSlug); - setWorkItemShortIds( - new Map(viewData.items.map((item) => [item.id, item.shortId])) - ); - setWorkItems(viewData.items.map(enrichedWorkItemToUI)); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to load work items"; - logger.error("Failed to load project work items:", error); - setWorkItemsError(message); - } finally { - setWorkItemsLoading(false); - } - }, [projectSlug]); - - useEffect(() => { - void loadProjectWorkItems(); - }, [loadProjectWorkItems]); + const fetchProjectWorkItems = useCallback( + async (scopeProjectSlug: string) => { + const viewData = await projectApi.readWorkItemsViewData(scopeProjectSlug); + return { + shortIds: new Map( + viewData.items.map((item) => [item.id, item.shortId]) + ), + workItems: viewData.items.map(enrichedWorkItemToUI), + }; + }, + [] + ); + const workItemsResource = useAsyncResource({ + enabled: Boolean(projectSlug), + fetcher: fetchProjectWorkItems, + initialData: EMPTY_PROJECT_WORK_ITEMS, + scopeKey: projectSlug || null, + }); + const { + data: { shortIds: workItemShortIds, workItems }, + error: workItemsError, + loading: workItemsLoading, + refresh: loadProjectWorkItems, + setData: setWorkItemsData, + } = workItemsResource; useProjectDataChanged( useCallback(() => { @@ -483,13 +485,14 @@ export const ProjectPanelView: React.FC = ({ payload ); const updatedItem = enrichedWorkItemToUI(updated); - setWorkItems((currentItems) => - currentItems.map((item) => + setWorkItemsData((current) => ({ + ...current, + workItems: current.workItems.map((item) => item.session_id === workItemId ? updatedItem : item - ) - ); + ), + })); }, - [currentUser, getWorkItemShortId, projectSlug, setWorkItems] + [currentUser, getWorkItemShortId, projectSlug, setWorkItemsData] ); const handleAddKanbanTask = useCallback( diff --git a/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts b/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts index 3cd0007caf..fdaa69d55d 100644 --- a/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts +++ b/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts @@ -10,7 +10,7 @@ * useAgentCompatibility() stays in sync. */ import { useSetAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import type { AgentInfo, ProviderInfo } from "@src/api/http/config"; import { loadAvailableAgents } from "@src/api/services/availableAgents"; @@ -20,6 +20,10 @@ import type { AvailableApiProvider, KeyInfo, } from "@src/api/tauri/rpc/schemas/validation"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { loadSharedLocalKeys } from "@src/hooks/keyVault/sharedLocalKeyStore"; import { createLogger } from "@src/hooks/logger"; import { agentRegistryAtom } from "@src/store/session/agentRegistryAtom"; @@ -143,6 +147,47 @@ function mapAgents(agents: AvailableAgent[]): AgentInfo[] { })); } +interface SessionDiscoveryData { + apiProviders: AvailableApiProvider[]; + mappedAgents: AgentInfo[]; + providers: ProviderInfo[]; + rawAgents: AvailableAgent[]; +} + +const EMPTY_SESSION_DISCOVERY: SessionDiscoveryData = { + apiProviders: [], + mappedAgents: [], + providers: [], + rawAgents: [], +}; + +let discoveryInFlight: Promise | null = null; + +function loadSessionDiscovery(force: boolean): Promise { + if (!force && discoveryInFlight) return discoveryInFlight; + + const promise = Promise.all([ + rpc.validation.getAvailableApiProviders(), + loadAvailableAgents(), + loadSharedLocalKeys(force), + ]).then(([apiProviders, rawAgents, allKeys]) => ({ + apiProviders, + mappedAgents: mapAgents(rawAgents), + providers: buildProviderInfoList(apiProviders, allKeys), + rawAgents, + })); + discoveryInFlight = promise; + void promise.then( + () => { + if (discoveryInFlight === promise) discoveryInFlight = null; + }, + () => { + if (discoveryInFlight === promise) discoveryInFlight = null; + } + ); + return promise; +} + // ============================================ // Hook Implementation // ============================================ @@ -151,22 +196,44 @@ export function useSessionDiscovery( options: UseSessionDiscoveryOptions = {} ): UseSessionDiscoveryReturn { const { autoLoad = true, onSuccess, onError } = options; - - const [providers, setProviders] = useState([]); - const [agents, setAgents] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const hasLoadedRef = useRef(false); - const mountedRef = useRef(true); - const setAgentRegistry = useSetAtom(agentRegistryAtom); - + const callbacksRef = useRef({ onError, onSuccess }); useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); + callbacksRef.current = { onError, onSuccess }; + }, [onError, onSuccess]); + + const fetchDiscovery = useCallback( + async ( + _scopeKey: string, + context: AsyncResourceFetchContext + ) => { + try { + const data = await loadSessionDiscovery(context.cause === "refresh"); + callbacksRef.current.onSuccess?.({ + agents: data.mappedAgents, + providers: data.providers, + }); + return data; + } catch (error) { + const normalizedError = + error instanceof Error + ? error + : new Error("Failed to load session data"); + log.error("[useSessionDiscovery] Refresh failed:", error); + callbacksRef.current.onError?.(normalizedError); + throw normalizedError; + } + }, + [] + ); + const resource = useAsyncResource({ + autoLoad, + fetcher: fetchDiscovery, + initialData: EMPTY_SESSION_DISCOVERY, + scopeKey: "session-discovery", + }); + const providers = resource.data.providers; + const agents = resource.data.mappedAgents; const availableAgents = useMemo( () => agents.filter((agent) => agent.available), @@ -201,56 +268,14 @@ export function useSessionDiscovery( [agents] ); - // ============================================ - // Refresh - // ============================================ - - const refresh = useCallback(async () => { - if (!mountedRef.current) return; - setLoading(true); - setError(null); - - try { - const [apiProviders, rawAgents, allKeys] = await Promise.all([ - rpc.validation.getAvailableApiProviders(), - loadAvailableAgents(), - loadSharedLocalKeys(), - ]); - - if (!mountedRef.current) return; - - // Populate agentRegistryAtom so useAgentCompatibility stays current - setAgentRegistry({ agents: rawAgents, apiProviders }); - - const mappedProviders = buildProviderInfoList(apiProviders, allKeys); - const mappedAgents = mapAgents(rawAgents); - - setProviders(mappedProviders); - setAgents(mappedAgents); - - onSuccess?.({ providers: mappedProviders, agents: mappedAgents }); - } catch (err) { - if (!mountedRef.current) return; - const errorMessage = - err instanceof Error ? err.message : "Failed to load session data"; - log.error("[useSessionDiscovery] Refresh failed:", err); - setError(errorMessage); - onError?.(err as Error); - } finally { - if (mountedRef.current) setLoading(false); - } - }, [onSuccess, onError, setAgentRegistry]); - - // ============================================ - // Effects - // ============================================ - useEffect(() => { - if (autoLoad && !hasLoadedRef.current) { - hasLoadedRef.current = true; - refresh(); + if (resource.status === "ready") { + setAgentRegistry({ + agents: resource.data.rawAgents, + apiProviders: resource.data.apiProviders, + }); } - }, [autoLoad, refresh]); + }, [resource.data, resource.status, setAgentRegistry]); // ============================================ // Return @@ -260,9 +285,9 @@ export function useSessionDiscovery( providers, agents, availableAgents, - loading, - error, - refresh, + loading: resource.loading, + error: resource.error, + refresh: resource.refresh, getModelsForProvider, isProviderAvailable, isAgentAvailable, diff --git a/src/hooks/dependencies/useSystemDependencies.ts b/src/hooks/dependencies/useSystemDependencies.ts index b54758db61..ac39fd6434 100644 --- a/src/hooks/dependencies/useSystemDependencies.ts +++ b/src/hooks/dependencies/useSystemDependencies.ts @@ -5,8 +5,12 @@ * Returns the full list plus helpers for filtering by category. */ import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; const log = createLogger("Dependencies"); @@ -49,58 +53,58 @@ export const NON_DB_CATEGORIES: DepCategoryId[] = [ ]; export function useSystemDependencies() { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isRefreshing, setIsRefreshing] = useState(false); - - useEffect(() => { - let cancelled = false; - - invoke("get_cached_dependencies") - .then((result) => { - if (!cancelled) { - setData(result); - setIsLoading(false); - } - }) - .catch((err: unknown) => { - // Cache miss is non-fatal — `detect_system_dependencies` below - // performs the live scan. Surface the failure for debugging. - log.warn("[Dependencies] cache load failed:", err); - }); - - invoke("detect_system_dependencies") - .then((result) => { - if (!cancelled) { - setData(result); - setIsLoading(false); - } - }) - .catch((error) => { - if (!cancelled) { - log.error("[Dependencies] scan failed:", error); - setIsLoading(false); + const fetchDependencies = useCallback( + async ( + _scopeKey: string, + context: AsyncResourceFetchContext + ) => { + if (context.cause !== "load") { + return invoke("detect_system_dependencies"); + } + + let liveSettled = false; + const cachedPromise = invoke( + "get_cached_dependencies" + ) + .then((cached) => { + if (!liveSettled) context.publish(cached); + return cached; + }) + .catch((error: unknown) => { + log.warn("[Dependencies] cache load failed:", error); + throw error; + }); + const livePromise = invoke( + "detect_system_dependencies" + ).then( + (result) => { + liveSettled = true; + return result; + }, + (error: unknown) => { + liveSettled = true; + throw error; } - }); + ); - return () => { - cancelled = true; - }; - }, []); + const [cachedResult, liveResult] = await Promise.allSettled([ + cachedPromise, + livePromise, + ]); + if (liveResult.status === "fulfilled") return liveResult.value; + log.error("[Dependencies] scan failed:", liveResult.reason); + if (cachedResult.status === "fulfilled") return cachedResult.value; + throw liveResult.reason; + }, + [] + ); - const refresh = useCallback(async () => { - setIsRefreshing(true); - try { - const result = await invoke( - "detect_system_dependencies" - ); - setData(result); - } catch (error) { - log.error("[Dependencies] refresh failed:", error); - } finally { - setIsRefreshing(false); - } - }, []); + const resource = useAsyncResource({ + fetcher: fetchDependencies, + initialData: null, + scopeKey: "system-dependencies", + }); + const { data, refresh, refreshing, status } = resource; const dependencies = useMemo(() => data?.dependencies ?? [], [data]); @@ -114,8 +118,8 @@ export function useSystemDependencies() { return { dependencies, - isLoading, - isRefreshing, + isLoading: status === "loading", + isRefreshing: refreshing, refresh, byCategory, }; diff --git a/src/hooks/git/useFileHistory.ts b/src/hooks/git/useFileHistory.ts index f0ae0626c8..891db4bde2 100644 --- a/src/hooks/git/useFileHistory.ts +++ b/src/hooks/git/useFileHistory.ts @@ -3,9 +3,13 @@ * * Fetches Git commit history for a specific file using the Rust Git API. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { type GitCommitInfo, getGitCommits } from "@src/api/http/git"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; export interface UseFileHistoryOptions { /** Repository ID */ @@ -35,6 +39,16 @@ export interface UseFileHistoryResult { totalCount: number | null; } +interface FileHistoryData { + commits: GitCommitInfo[]; + totalCount: number | null; +} + +const EMPTY_FILE_HISTORY: FileHistoryData = { + commits: [], + totalCount: null, +}; + /** * Hook to fetch and manage file commit history */ @@ -46,69 +60,63 @@ export function useFileHistory({ onSuccess, onError, }: UseFileHistoryOptions): UseFileHistoryResult { - const [commits, setCommits] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [totalCount, setTotalCount] = useState(null); - - // Callback props are mirrored into refs so `refresh` stays stable. Keeping - // them in the dep array meant any caller passing an inline arrow rebuilt - // `refresh` every render, and the autoLoad effect below — keyed on - // `refresh` — would then re-issue GET /commits on every render. const onSuccessRef = useRef(onSuccess); - onSuccessRef.current = onSuccess; const onErrorRef = useRef(onError); - onErrorRef.current = onError; - - const refresh = useCallback(async () => { - // Don't fetch if no file is selected - if (!filePath) { - setCommits([]); - setTotalCount(null); - return; - } - - setLoading(true); - setError(null); - - try { - const result = await getGitCommits({ - repo_id: repoId, - file_path: filePath, - limit, - }); + useEffect(() => { + onSuccessRef.current = onSuccess; + onErrorRef.current = onError; + }, [onError, onSuccess]); - if (result) { - setCommits(result.commits); - setTotalCount(result.total_count); - onSuccessRef.current?.(result.commits); - } else { - setCommits([]); - setTotalCount(null); + const fetchHistory = useCallback( + async ( + serializedScope: string, + context: AsyncResourceFetchContext + ): Promise => { + const scope = JSON.parse(serializedScope) as { + filePath: string; + limit: number; + repoId: string; + }; + try { + const result = await getGitCommits({ + repo_id: scope.repoId, + file_path: scope.filePath, + limit: scope.limit, + }); + const data = result + ? { commits: result.commits, totalCount: result.total_count } + : EMPTY_FILE_HISTORY; + if (context.isCurrent()) { + onSuccessRef.current?.(data.commits); + } + return data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (context.isCurrent()) { + context.publish(EMPTY_FILE_HISTORY); + onErrorRef.current?.(message); + } + throw error; } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - setError(errorMessage); - setCommits([]); - setTotalCount(null); - onErrorRef.current?.(errorMessage); - } finally { - setLoading(false); - } - }, [repoId, filePath, limit]); - - // Auto-load on mount or when dependencies change - useEffect(() => { - if (autoLoad) { - refresh(); - } - }, [autoLoad, refresh]); + }, + [] + ); + const scopeKey = filePath + ? JSON.stringify({ filePath, limit, repoId }) + : null; + const resource = useAsyncResource({ + autoLoad, + enabled: Boolean(scopeKey), + fetcher: fetchHistory, + initialData: EMPTY_FILE_HISTORY, + scopeKey, + }); return { - commits, - loading, - error, - refresh, - totalCount, + commits: resource.data.commits, + loading: resource.loading, + error: resource.error, + refresh: resource.refresh, + totalCount: resource.data.totalCount, }; } diff --git a/src/hooks/git/useOrgtrackFileTimeline.ts b/src/hooks/git/useOrgtrackFileTimeline.ts index 5cf57509eb..c087234feb 100644 --- a/src/hooks/git/useOrgtrackFileTimeline.ts +++ b/src/hooks/git/useOrgtrackFileTimeline.ts @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { type OrgtrackFileTimeline, getOrgtrackFileTimeline, } from "@src/api/tauri/lineage"; +import { useAsyncResource } from "@src/hooks/async"; export interface UseOrgtrackFileTimelineOptions { repoPath: string; @@ -23,33 +24,27 @@ export function useOrgtrackFileTimeline({ filePath, autoLoad = true, }: UseOrgtrackFileTimelineOptions): UseOrgtrackFileTimelineResult { - const [timeline, setTimeline] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const refresh = useCallback(async () => { - if (!filePath || !repoPath) { - setTimeline(null); - return; - } - - setLoading(true); - setError(null); - try { - setTimeline(await getOrgtrackFileTimeline({ repoPath, filePath })); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setTimeline(null); - } finally { - setLoading(false); - } - }, [filePath, repoPath]); - - useEffect(() => { - if (autoLoad) { - void refresh(); - } - }, [autoLoad, refresh]); - - return { timeline, loading, error, refresh }; + const fetchTimeline = useCallback(async (serializedScope: string) => { + const scope = JSON.parse(serializedScope) as { + filePath: string; + repoPath: string; + }; + return getOrgtrackFileTimeline(scope); + }, []); + const scopeKey = + filePath && repoPath ? JSON.stringify({ filePath, repoPath }) : null; + const resource = useAsyncResource({ + autoLoad, + enabled: Boolean(scopeKey), + fetcher: fetchTimeline, + initialData: null, + scopeKey, + }); + + return { + timeline: resource.data, + loading: resource.loading, + error: resource.error, + refresh: resource.refresh, + }; } diff --git a/src/hooks/policies/useSharedPolicies.ts b/src/hooks/policies/useSharedPolicies.ts index d701110414..b506a19037 100644 --- a/src/hooks/policies/useSharedPolicies.ts +++ b/src/hooks/policies/useSharedPolicies.ts @@ -5,8 +5,9 @@ * `.orgii/rules/` files + per-rule agent scope in `rules-config.json`. */ import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; const log = createLogger("SharedPolicies"); @@ -44,64 +45,29 @@ export interface UseSharedPoliciesOptions { export function useSharedPolicies(options: UseSharedPoliciesOptions = {}) { const { workspacePath, autoLoad = true } = options; - const [policies, setPolicies] = useState([]); - // Default false so remounts of this hook on navigation don't paint - // a synthetic spinner before the IPC begins. `refresh` below raises - // loading to true for the actual fetch window; Placeholder's loading - // variant debounces sub-250ms spinners globally. - const [loading, setLoading] = useState(false); - const cancelRef = useRef<(() => void) | null>(null); - - const refresh = useCallback(() => { - cancelRef.current?.(); - let cancelled = false; - cancelRef.current = () => { - cancelled = true; - }; - - setLoading(true); - invoke("policies_list", { - workspacePath: workspacePath ?? null, - }) - .then((result) => { - if (!cancelled) setPolicies(result); - }) - .catch((err: unknown) => { - if (!cancelled) - log.error("[SharedPolicies] Failed to list policies:", err); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - }, [workspacePath]); - - useEffect(() => { - if (!autoLoad) return; - - cancelRef.current?.(); - let cancelled = false; - cancelRef.current = () => { - cancelled = true; + const fetchPolicies = useCallback(async (serializedScope: string) => { + const scope = JSON.parse(serializedScope) as { + workspacePath: string | null; }; - - invoke("policies_list", { - workspacePath: workspacePath ?? null, - }) - .then((result) => { - if (!cancelled) setPolicies(result); - }) - .catch((err: unknown) => { - if (!cancelled) - log.error("[SharedPolicies] Failed to list policies:", err); - }) - .finally(() => { - if (!cancelled) setLoading(false); + try { + return await invoke("policies_list", { + workspacePath: scope.workspacePath, }); - - return () => { - cancelled = true; - }; - }, [workspacePath, autoLoad]); + } catch (error) { + log.error("[SharedPolicies] Failed to list policies:", error); + throw error; + } + }, []); + const policyResource = useAsyncResource({ + autoLoad, + fetcher: fetchPolicies, + initialData: [], + scopeKey: JSON.stringify({ workspacePath: workspacePath ?? null }), + }); + const policies = policyResource.data; + const loading = policyResource.loading; + const refresh = policyResource.refresh; + const setPolicies = policyResource.setData; const readRule = useCallback( async ( @@ -208,7 +174,7 @@ export function useSharedPolicies(options: UseSharedPoliciesOptions = {}) { throw err; } }, - [workspacePath, refresh] + [workspacePath, refresh, setPolicies] ); const setAgents = useCallback( diff --git a/src/hooks/settings/useLearningsBrowser.test.ts b/src/hooks/settings/useLearningsBrowser.test.ts new file mode 100644 index 0000000000..5fb7066112 --- /dev/null +++ b/src/hooks/settings/useLearningsBrowser.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { LearningRecord } from "@src/api/tauri/rpc/schemas/learning"; + +import { + type UseLearningsBrowserReturn, + useLearningsBrowser, +} from "./useLearningsBrowser"; + +const learningMocks = vi.hoisted(() => ({ + browseList: vi.fn(), + getStatus: vi.fn(), + remove: vi.fn(), + setStatus: vi.fn(), +})); + +vi.mock("@src/api/tauri/rpc", () => ({ + rpc: { + learning: learningMocks, + }, +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("useLearningsBrowser", () => { + let container: HTMLDivElement; + let root: Root; + let current: UseLearningsBrowserReturn; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + function Harness() { + const result = useLearningsBrowser(); + useEffect(() => { + current = result; + }, [result]); + return createElement("div", { + "data-item": result.items[0]?.id ?? "", + "data-loading": String(result.loading), + }); + } + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + learningMocks.browseList.mockReset(); + learningMocks.getStatus.mockReset(); + learningMocks.remove.mockReset(); + learningMocks.setStatus.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("keeps the newest filter result when an older request finishes last", async () => { + const oldList = deferred(); + const oldStatus = deferred(); + const newList = deferred(); + const newStatus = deferred(); + learningMocks.browseList + .mockReturnValueOnce(oldList.promise) + .mockReturnValueOnce(newList.promise); + learningMocks.getStatus + .mockReturnValueOnce(oldStatus.promise) + .mockReturnValueOnce(newStatus.promise); + + act(() => root.render(createElement(Harness))); + act(() => current.setFilters({ search: "new" })); + + newList.resolve([ + { id: "new", updated_at: "2026-07-23T10:00:00Z" } as LearningRecord, + ]); + newStatus.resolve({} as never); + await flush(); + expect(container.firstElementChild?.getAttribute("data-item")).toBe("new"); + + oldList.resolve([ + { id: "old", updated_at: "2026-07-22T10:00:00Z" } as LearningRecord, + ]); + oldStatus.resolve({} as never); + await flush(); + + expect(container.firstElementChild?.getAttribute("data-item")).toBe("new"); + expect(learningMocks.browseList).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/hooks/settings/useLearningsBrowser.ts b/src/hooks/settings/useLearningsBrowser.ts index b503967130..1d8b594003 100644 --- a/src/hooks/settings/useLearningsBrowser.ts +++ b/src/hooks/settings/useLearningsBrowser.ts @@ -7,7 +7,7 @@ * so the hook lives under `src/hooks/settings/` (single-module use). */ import { useAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { rpc } from "@src/api/tauri/rpc"; import type { @@ -18,6 +18,7 @@ import type { LearningsStatusReport, SettableLearningStatusValue, } from "@src/api/tauri/rpc/schemas/learning"; +import { useAsyncResource } from "@src/hooks/async"; import { learningsBrowserInitialFilterAtom } from "@src/store"; export interface LearningsBrowserFilters { @@ -47,88 +48,96 @@ export interface UseLearningsBrowserReturn { remove: (id: string) => Promise; } +interface LearningsBrowserData { + items: LearningRecord[]; + status: LearningsStatusReport | null; +} + +interface LearningsBrowserRequest { + agentScopes?: string[]; + filters: LearningsBrowserFilters; +} + +const EMPTY_LEARNINGS_BROWSER_DATA: LearningsBrowserData = { + items: [], + status: null, +}; + export function useLearningsBrowser( options: UseLearningsBrowserOptions = {} ): UseLearningsBrowserReturn { - const [items, setItems] = useState([]); - const [status, setStatusReport] = useState( - null - ); - const [filters, setFiltersState] = useState({}); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const [initialFilter, setInitialFilter] = useAtom( learningsBrowserInitialFilterAtom ); + const [filters, setFiltersState] = useState(() => + initialFilter ? { status: initialFilter } : {} + ); useEffect(() => { if (initialFilter) { - setFiltersState((prev) => ({ ...prev, status: initialFilter })); setInitialFilter(null); } }, [initialFilter, setInitialFilter]); - const fetchAll = useCallback( - async (current: LearningsBrowserFilters) => { - setLoading(true); - setError(null); - try { - const scopes = current.agentScope - ? [current.agentScope] - : options.agentScopes; - if (scopes && scopes.length > 0) { - const lists = await Promise.all( - scopes.map((agentScope) => - rpc.learning.browseList({ - agentScope, - status: current.status, - source: current.source, - category: current.category, - search: current.search, - }) - ) - ); - const byId = new Map(); - for (const list of lists) { - for (const row of list) byId.set(row.id, row); - } - const merged = [...byId.values()].sort((rowA, rowB) => - rowB.updated_at.localeCompare(rowA.updated_at) - ); - setItems(merged); - setStatusReport(null); - return; - } - - const [list, report] = await Promise.all([ + const scopeKey = useMemo( + () => + JSON.stringify({ + agentScopes: options.agentScopes, + filters, + } satisfies LearningsBrowserRequest), + [filters, options.agentScopes] + ); + + const fetchAll = useCallback(async (serializedRequest: string) => { + const request = JSON.parse(serializedRequest) as LearningsBrowserRequest; + const current = request.filters; + const scopes = current.agentScope + ? [current.agentScope] + : request.agentScopes; + if (scopes && scopes.length > 0) { + const lists = await Promise.all( + scopes.map((agentScope) => rpc.learning.browseList({ - agentScope: current.agentScope, + agentScope, status: current.status, source: current.source, category: current.category, search: current.search, - }), - rpc.learning.getStatus({ agentScope: current.agentScope }), - ]); - setItems(list); - setStatusReport(report); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setError(message); - } finally { - setLoading(false); + }) + ) + ); + const byId = new Map(); + for (const list of lists) { + for (const row of list) byId.set(row.id, row); } - }, - [options.agentScopes] - ); + return { + items: [...byId.values()].sort((rowA, rowB) => + rowB.updated_at.localeCompare(rowA.updated_at) + ), + status: null, + }; + } - useEffect(() => { - void fetchAll(filters); - }, [filters, fetchAll]); + const [items, status] = await Promise.all([ + rpc.learning.browseList({ + agentScope: current.agentScope, + status: current.status, + source: current.source, + category: current.category, + search: current.search, + }), + rpc.learning.getStatus({ agentScope: current.agentScope }), + ]); + return { items, status }; + }, []); + + const resource = useAsyncResource({ + fetcher: fetchAll, + initialData: EMPTY_LEARNINGS_BROWSER_DATA, + scopeKey, + }); - const refresh = useCallback(async () => { - await fetchAll(filters); - }, [fetchAll, filters]); + const refresh = resource.refresh; const setFilters = useCallback((next: LearningsBrowserFilters) => { setFiltersState(next); @@ -137,25 +146,25 @@ export function useLearningsBrowser( const setStatus = useCallback( async (id: string, next: SettableLearningStatusValue) => { await rpc.learning.setStatus({ learningId: id, next }); - await fetchAll(filters); + await refresh(); }, - [fetchAll, filters] + [refresh] ); const remove = useCallback( async (id: string) => { await rpc.learning.remove({ learningId: id }); - await fetchAll(filters); + await refresh(); }, - [fetchAll, filters] + [refresh] ); return { - items, - loading, - error, + items: resource.data.items, + loading: resource.loading, + error: resource.error, filters, - status, + status: resource.data.status, setFilters, refresh, setStatus, diff --git a/src/hooks/skills/useSkillsHub.ts b/src/hooks/skills/useSkillsHub.ts index 5d9ce06581..b04054775c 100644 --- a/src/hooks/skills/useSkillsHub.ts +++ b/src/hooks/skills/useSkillsHub.ts @@ -10,6 +10,10 @@ import { invoke } from "@tauri-apps/api/core"; import { useAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { useMounted } from "@src/hooks/lifecycle/useMounted"; import { createLogger } from "@src/hooks/logger"; import { scanInstalledSkills } from "@src/hooks/skills/installedSkillsScan"; @@ -64,9 +68,7 @@ export function useSkillsHub({ installedSkillsLoadingAtom ); - const [skillDetail, setSkillDetail] = useState(null); - const [detailLoading, setDetailLoading] = useState(false); - const [detailError, setDetailError] = useState(null); + const [detailSlug, setDetailSlug] = useState(null); const [updates, setUpdates] = useState([]); const [updatesLoading, setUpdatesLoading] = useState(false); @@ -193,55 +195,61 @@ export function useSkillsHub({ setInstalledSkills, ]); - const fetchDetail = useCallback(async (slug: string) => { - setDetailLoading(true); - setDetailError(null); - setSkillDetail(null); - - let hasCached = false; - - // 1. Try loading cached detail first for instant display - try { - const cached = await invoke( - "skills_hub_detail_cache_read", - { name: slug } - ); - if (cached) { - setSkillDetail(cached); - setDetailLoading(false); - hasCached = true; + const loadSkillDetail = useCallback( + async ( + slug: string, + context: AsyncResourceFetchContext + ) => { + let cached: HubSkillDetail | null = null; + try { + cached = await invoke( + "skills_hub_detail_cache_read", + { name: slug } + ); + if (cached) context.publish(cached); + } catch { + // Cache miss is fine, continue to network. } - } catch { - // Cache miss is fine, continue to network - } - // 2. Fetch fresh detail from network (background refresh if cached) - try { - const detail = await invoke("skills_hub_detail", { - slug, - }); - setSkillDetail(detail); + try { + const detail = await invoke("skills_hub_detail", { + slug, + }); + void invoke("skills_hub_detail_cache_write", { + name: slug, + detail, + }).catch(() => { + // Cache write failure is non-critical. + }); + return detail; + } catch (error) { + if (cached) return cached; + throw error; + } + }, + [] + ); + const detailResource = useAsyncResource({ + enabled: Boolean(detailSlug), + fetcher: loadSkillDetail, + initialData: null, + scopeKey: detailSlug, + }); + const refreshDetail = detailResource.refresh; - // 3. Persist to cache for offline access - invoke("skills_hub_detail_cache_write", { - name: slug, - detail, - }).catch(() => { - // Cache write failure is non-critical - }); - } catch (err) { - if (!hasCached) { - setDetailError(err instanceof Error ? err.message : String(err)); + const fetchDetail = useCallback( + (slug: string) => { + if (slug === detailSlug) { + void refreshDetail(); + return; } - } finally { - setDetailLoading(false); - } - }, []); + setDetailSlug(slug); + }, + [detailSlug, refreshDetail] + ); const clearDetail = useCallback(() => { - setSkillDetail(null); - setDetailError(null); - setDetailLoading(false); + setDetailSlug(null); }, []); const uninstall = useCallback( @@ -318,9 +326,9 @@ export function useSkillsHub({ toggleSkill, uninstall, readSkill, - skillDetail, - detailLoading, - detailError, + skillDetail: detailResource.data, + detailLoading: detailResource.loading, + detailError: detailResource.error, fetchDetail, clearDetail, updates, diff --git a/src/hooks/terminal/useAvailableShells.ts b/src/hooks/terminal/useAvailableShells.ts index 6e1cf5ec00..c3dbc92a0a 100644 --- a/src/hooks/terminal/useAvailableShells.ts +++ b/src/hooks/terminal/useAvailableShells.ts @@ -7,8 +7,9 @@ * Results are cached after the first successful fetch — the set of available * shells doesn't change during a single app session. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; +import { useAsyncResource } from "@src/hooks/async"; import type { DetectedShell, ShellProfile } from "@src/types/terminal"; import { invokeTauri, isTauriReady } from "@src/util/platform/tauri/init"; @@ -20,6 +21,7 @@ interface UseAvailableShellsReturn { } let cachedProfiles: ShellProfile[] | null = null; +const EMPTY_SHELL_PROFILES: ShellProfile[] = []; function detectedShellToProfile(shell: DetectedShell): ShellProfile { return { @@ -35,44 +37,32 @@ function detectedShellToProfile(shell: DetectedShell): ShellProfile { } export function useAvailableShells(): UseAvailableShellsReturn { - const [profiles, setProfiles] = useState( - cachedProfiles ?? [] - ); - const [loading, setLoading] = useState(cachedProfiles === null); - const [error, setError] = useState(null); - const fetchedRef = useRef(cachedProfiles !== null); - const fetchShells = useCallback(async () => { - if (!isTauriReady()) return; - - setLoading(true); - setError(null); - - try { - const detected = await invokeTauri( - "detect_available_shells" - ); - const mapped = detected.map(detectedShellToProfile); - cachedProfiles = mapped; - setProfiles(mapped); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } + if (cachedProfiles) return cachedProfiles; + if (!isTauriReady()) return EMPTY_SHELL_PROFILES; + const detected = await invokeTauri( + "detect_available_shells" + ); + cachedProfiles = detected.map(detectedShellToProfile); + return cachedProfiles; }, []); - - useEffect(() => { - if (!fetchedRef.current) { - fetchedRef.current = true; - fetchShells(); - } - }, [fetchShells]); + const resource = useAsyncResource({ + fetcher: fetchShells, + initialData: cachedProfiles ?? EMPTY_SHELL_PROFILES, + initialStatus: cachedProfiles ? "ready" : "idle", + scopeKey: "available-shells", + }); + const refreshResource = resource.refresh; const refresh = useCallback(() => { cachedProfiles = null; - fetchShells(); - }, [fetchShells]); + void refreshResource(); + }, [refreshResource]); - return { profiles, loading, error, refresh }; + return { + profiles: resource.data, + loading: resource.loading, + error: resource.error, + refresh, + }; } diff --git a/src/modules/MainApp/AgentOrgs/config/osAgent/useOSAgentGateway.ts b/src/modules/MainApp/AgentOrgs/config/osAgent/useOSAgentGateway.ts index 0dbd4eb597..21b87085f1 100644 --- a/src/modules/MainApp/AgentOrgs/config/osAgent/useOSAgentGateway.ts +++ b/src/modules/MainApp/AgentOrgs/config/osAgent/useOSAgentGateway.ts @@ -4,15 +4,15 @@ * Manages gateway status polling and start/stop actions. * Polls every 10s when loaded so live connection status stays fresh. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { getGatewayStatus, startGateway, stopGateway, } from "@src/api/tauri/agent"; +import { useVisibilityPolledData } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; -import { startVisibilityAwarePoller } from "@src/shared/scheduling/visibilityAwarePoller"; import type { GatewayStatusInfo } from "./types"; @@ -29,29 +29,27 @@ export interface UseOSAgentGatewayReturn { } export function useOSAgentGateway(loaded: boolean): UseOSAgentGatewayReturn { - const [gatewayStatus, setGatewayStatus] = useState( - null - ); const [gatewayLoading, setGatewayLoading] = useState(false); - const refreshGatewayStatus = useCallback(async () => { + + const fetchGatewayStatus = useCallback(async () => { try { - const status = await getGatewayStatus(); - setGatewayStatus(status as unknown as GatewayStatusInfo); + return (await getGatewayStatus()) as unknown as GatewayStatusInfo; } catch (err) { log.warn("Failed to fetch OS agent gateway status:", err); - setGatewayStatus(null); + return null; } }, []); - - useEffect(() => { - if (!loaded) return; - - return startVisibilityAwarePoller( - document, - refreshGatewayStatus, - POLL_INTERVAL_MS - ); - }, [loaded, refreshGatewayStatus]); + const gatewayResource = useVisibilityPolledData({ + enabled: loaded, + fetcher: fetchGatewayStatus, + initialData: null, + intervalMs: POLL_INTERVAL_MS, + scopeKey: loaded ? "os-agent-gateway" : null, + }); + const refreshGateway = gatewayResource.refresh; + const refreshGatewayStatus = useCallback(() => { + void refreshGateway(); + }, [refreshGateway]); const handleStartGateway = useCallback(async () => { setGatewayLoading(true); @@ -80,7 +78,7 @@ export function useOSAgentGateway(loaded: boolean): UseOSAgentGatewayReturn { }, [refreshGatewayStatus]); return { - gatewayStatus, + gatewayStatus: gatewayResource.data, gatewayLoading, refreshGatewayStatus, handleStartGateway, diff --git a/src/modules/MainApp/AgentOrgs/config/skills/useSkills.ts b/src/modules/MainApp/AgentOrgs/config/skills/useSkills.ts index 601b9c54fa..d65c504aa9 100644 --- a/src/modules/MainApp/AgentOrgs/config/skills/useSkills.ts +++ b/src/modules/MainApp/AgentOrgs/config/skills/useSkills.ts @@ -1,9 +1,10 @@ /** * Hook for managing coding agent skills. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; import { rpc } from "@src/api/tauri/rpc"; +import { useAsyncResource } from "@src/hooks/async"; import type { DescriptionQuality } from "@src/types/extensions/types"; export interface SkillInfo { @@ -30,43 +31,21 @@ export interface SkillInfo { * agent UIs so per-agent toggles do not silently rewrite OS/SDE state. */ export function useSkills(workspacePath?: string, agentId?: string) { - const [skills, setSkills] = useState([]); - // Default false: a fresh mount of this hook should not flash a - // spinner before the IPC even kicks off. `refresh` raises loading - // for the actual fetch window; the Placeholder loading variant is - // debounced to suppress sub-250ms flashes globally. - const [loading, setLoading] = useState(false); - const cancelRef = useRef<(() => void) | null>(null); - - const refresh = useCallback(() => { - cancelRef.current?.(); - let cancelled = false; - cancelRef.current = () => { - cancelled = true; - }; - - queueMicrotask(() => { - if (!cancelled) setLoading(true); - }); - rpc.agentOrgs.skills - .list({ workspacePath, agentId }) - .then((result) => { - if (!cancelled) setSkills(result); - }) - .catch(() => { - // Fetch failure: leave existing skills displayed. - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - }, [workspacePath, agentId]); - - useEffect(() => { - refresh(); - return () => { - cancelRef.current?.(); + const fetchSkills = useCallback(async (serializedScope: string) => { + const scope = JSON.parse(serializedScope) as { + agentId?: string; + workspacePath?: string; }; - }, [refresh]); + return rpc.agentOrgs.skills.list(scope); + }, []); + const scopeKey = JSON.stringify({ agentId, workspacePath }); + const resource = useAsyncResource({ + fetcher: fetchSkills, + initialData: [], + scopeKey, + }); + const setSkills = resource.setData; + const refresh = resource.refresh; const readSkill = useCallback( async (name: string) => { @@ -102,8 +81,14 @@ export function useSkills(workspacePath?: string, agentId?: string) { throw err; } }, - [workspacePath, agentId, refresh] + [workspacePath, agentId, refresh, setSkills] ); - return { skills, loading, refresh, readSkill, toggleSkill }; + return { + skills: resource.data, + loading: resource.loading, + refresh, + readSkill, + toggleSkill, + }; } diff --git a/src/modules/MainApp/AgentOrgs/hooks/useAgentOrgs.ts b/src/modules/MainApp/AgentOrgs/hooks/useAgentOrgs.ts index 47e241879e..b1ab998c35 100644 --- a/src/modules/MainApp/AgentOrgs/hooks/useAgentOrgs.ts +++ b/src/modules/MainApp/AgentOrgs/hooks/useAgentOrgs.ts @@ -4,10 +4,10 @@ * Returns the list of OrgMember (top-level org definitions) for use in * assignee pickers and orchestrator config resolution. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { rpc } from "@src/api/tauri/rpc"; -import { useMounted } from "@src/hooks/lifecycle/useMounted"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import type { OrgMember } from "../types"; @@ -15,42 +15,23 @@ import type { OrgMember } from "../types"; const log = createLogger("AgentOrgs"); export function useAgentOrgs() { - const [orgs, setOrgs] = useState([]); - const [loading, setLoading] = useState(false); - const mountedRef = useMounted(); - - const refresh = useCallback(async () => { - setLoading(true); + const fetchOrgs = useCallback(async () => { try { - const result = await rpc.agentOrgs.orgs.list(); - if (mountedRef.current) setOrgs(result); + return await rpc.agentOrgs.orgs.list(); } catch (error) { log.error("[AgentOrgs] Failed to fetch:", error); - } finally { - if (mountedRef.current) setLoading(false); + throw error; } - }, [mountedRef]); - - useEffect(() => { - let cancelled = false; - - const load = async () => { - setLoading(true); - try { - const result = await rpc.agentOrgs.orgs.list(); - if (!cancelled) setOrgs(result); - } catch (error) { - log.error("[AgentOrgs] Failed to fetch:", error); - } finally { - if (!cancelled) setLoading(false); - } - }; - load(); - - return () => { - cancelled = true; - }; }, []); - - return { orgs, loading, refresh }; + const resource = useAsyncResource({ + fetcher: fetchOrgs, + initialData: [], + scopeKey: "agent-orgs", + }); + + return { + orgs: resource.data, + loading: resource.loading, + refresh: resource.refresh, + }; } diff --git a/src/modules/MainApp/Inbox/hooks/useCommitFiles.ts b/src/modules/MainApp/Inbox/hooks/useCommitFiles.ts index 3846c1a641..ae5073fb82 100644 --- a/src/modules/MainApp/Inbox/hooks/useCommitFiles.ts +++ b/src/modules/MainApp/Inbox/hooks/useCommitFiles.ts @@ -6,13 +6,14 @@ * Caches results by commit SHA to avoid re-fetching. */ import { useAtomValue } from "jotai"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useRef } from "react"; import { getGitCommitDiff } from "@src/api/http/git/diff"; import type { CommitDiffResult, GitFileDiffStatus, } from "@src/api/http/git/types"; +import { useAsyncResource } from "@src/hooks/async"; import { selectedRepoIdAtom, selectedRepoPathAtom } from "@src/store/repo"; import { decodeOctalPath } from "@src/util/file/pathUtils"; @@ -31,91 +32,85 @@ interface UseCommitFilesResult { const MAX_CACHE_SIZE = 50; +interface CommitFilesData { + files: CommitFileInfo[]; + totalStats: { additions: number; deletions: number } | null; +} + +const EMPTY_COMMIT_FILES: CommitFilesData = { + files: [], + totalStats: null, +}; + +function mapCommitDiff(result: CommitDiffResult): CommitFilesData { + return { + files: result.files.map((file) => ({ + path: decodeOctalPath(file.file_path), + status: file.status, + additions: file.insertions ?? 0, + deletions: file.deletions ?? 0, + })), + totalStats: { + additions: result.stats?.insertions ?? 0, + deletions: result.stats?.deletions ?? 0, + }, + }; +} + export function useCommitFiles(messageId: string): UseCommitFilesResult { const selectedRepoId = useAtomValue(selectedRepoIdAtom); const selectedRepoPath = useAtomValue(selectedRepoPathAtom); - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(false); - const [totalStats, setTotalStats] = useState<{ - additions: number; - deletions: number; - } | null>(null); - const cacheRef = useRef>(new Map()); const isCommit = messageId.startsWith("git-commit-"); const commitSha = isCommit ? messageId.replace("git-commit-", "") : null; - useEffect(() => { - if (!commitSha || !selectedRepoId) { - setFiles([]); - setTotalStats(null); - return; - } + const fetchCommitFiles = useCallback(async (serializedScope: string) => { + const cached = cacheRef.current.get(serializedScope); + if (cached) return mapCommitDiff(cached); - // Check cache - const cached = cacheRef.current.get(commitSha); - if (cached) { - setFiles( - cached.files.map((file) => ({ - path: decodeOctalPath(file.file_path), - status: file.status, - additions: file.insertions ?? 0, - deletions: file.deletions ?? 0, - })) - ); - setTotalStats({ - additions: cached.stats?.insertions ?? 0, - deletions: cached.stats?.deletions ?? 0, + const scope = JSON.parse(serializedScope) as { + commitSha: string; + repoId: string; + repoPath: string | null; + }; + try { + const result = await getGitCommitDiff({ + repo_id: scope.repoId, + repo_path: scope.repoPath || undefined, + commit_sha: scope.commitSha, + context_lines: 0, }); - return; - } + if (!result) return EMPTY_COMMIT_FILES; - let cancelled = false; - setLoading(true); - - const fetchDiff = async () => { - try { - const result = await getGitCommitDiff({ - repo_id: selectedRepoId, - repo_path: selectedRepoPath || undefined, - commit_sha: commitSha, - context_lines: 0, // We only need stats, not full diff - }); - if (cancelled || !result) return; - - // Cache with eviction - if (cacheRef.current.size >= MAX_CACHE_SIZE) { - const firstKey = cacheRef.current.keys().next().value; - if (firstKey) cacheRef.current.delete(firstKey); - } - cacheRef.current.set(commitSha, result); - - setFiles( - result.files.map((file) => ({ - path: decodeOctalPath(file.file_path), - status: file.status, - additions: file.insertions ?? 0, - deletions: file.deletions ?? 0, - })) - ); - setTotalStats({ - additions: result.stats?.insertions ?? 0, - deletions: result.stats?.deletions ?? 0, - }); - } catch { - // Silently ignore — commit may not be accessible - } finally { - if (!cancelled) setLoading(false); + if (cacheRef.current.size >= MAX_CACHE_SIZE) { + const firstKey = cacheRef.current.keys().next().value; + if (firstKey) cacheRef.current.delete(firstKey); } - }; - - fetchDiff(); - - return () => { - cancelled = true; - }; - }, [commitSha, selectedRepoId, selectedRepoPath]); - - return { files, loading, totalStats }; + cacheRef.current.set(serializedScope, result); + return mapCommitDiff(result); + } catch { + return EMPTY_COMMIT_FILES; + } + }, []); + const scopeKey = + commitSha && selectedRepoId + ? JSON.stringify({ + commitSha, + repoId: selectedRepoId, + repoPath: selectedRepoPath, + }) + : null; + const resource = useAsyncResource({ + enabled: Boolean(scopeKey), + fetcher: fetchCommitFiles, + initialData: EMPTY_COMMIT_FILES, + scopeKey, + }); + + return { + files: resource.data.files, + loading: resource.loading, + totalStats: resource.data.totalStats, + }; } diff --git a/src/modules/MainApp/Integrations/BuiltInTools/useUnifiedToolsMetadata.ts b/src/modules/MainApp/Integrations/BuiltInTools/useUnifiedToolsMetadata.ts index 46cf2ad4a5..197895056c 100644 --- a/src/modules/MainApp/Integrations/BuiltInTools/useUnifiedToolsMetadata.ts +++ b/src/modules/MainApp/Integrations/BuiltInTools/useUnifiedToolsMetadata.ts @@ -4,8 +4,9 @@ * Uses module-level caching to prevent re-fetching on every component mount. * Similar to simulatorMap.ts caching pattern. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { invokeTauri } from "@src/util/platform/tauri/init"; @@ -19,6 +20,7 @@ const log = createLogger("Tools"); /** Cached tools list (null = not fetched yet). */ let cachedTools: RawToolInfo[] | null = null; +const EMPTY_TOOLS: RawToolInfo[] = []; /** In-flight fetch promise to prevent duplicate requests. */ let fetchPromise: Promise | null = null; @@ -63,50 +65,31 @@ export function clearToolsCache(): void { // ============================================ export function useUnifiedToolsMetadata() { - const [rawTools, setRawTools] = useState(cachedTools ?? []); - const [loading, setLoading] = useState(cachedTools === null); - const [error, setError] = useState(null); - - const refresh = useCallback(() => { - clearToolsCache(); - setLoading(true); - setError(null); - fetchToolsOnce() - .then((result) => { - setRawTools(result); - setLoading(false); - }) - .catch((err: unknown) => { - log.error("[Tools] Failed to list tools:", err); - setError(err instanceof Error ? err.message : String(err)); - setLoading(false); - }); - }, []); - - useEffect(() => { - if (cachedTools !== null) { - return; + const loadTools = useCallback(async () => { + try { + return await fetchToolsOnce(); + } catch (error) { + log.error("[Tools] Failed to list tools:", error); + throw error; } - - let cancelled = false; - fetchToolsOnce() - .then((result) => { - if (!cancelled) { - setRawTools(result); - setLoading(false); - } - }) - .catch((err: unknown) => { - log.error("[Tools] Failed to list tools:", err); - if (!cancelled) { - setError(err instanceof Error ? err.message : String(err)); - setLoading(false); - } - }); - return () => { - cancelled = true; - }; }, []); + const resource = useAsyncResource({ + fetcher: loadTools, + initialData: cachedTools ?? EMPTY_TOOLS, + initialStatus: cachedTools ? "ready" : "idle", + scopeKey: "unified-tools", + }); + const refreshResource = resource.refresh; - return { rawTools, loading, error, refresh }; + const refresh = useCallback(() => { + clearToolsCache(); + void refreshResource(); + }, [refreshResource]); + + return { + rawTools: resource.data, + loading: resource.loading, + error: resource.error, + refresh, + }; } diff --git a/src/modules/MainApp/Integrations/KeyVault/CliClients/hooks/useCliAgents.ts b/src/modules/MainApp/Integrations/KeyVault/CliClients/hooks/useCliAgents.ts index 629c28b08a..8d521306ac 100644 --- a/src/modules/MainApp/Integrations/KeyVault/CliClients/hooks/useCliAgents.ts +++ b/src/modules/MainApp/Integrations/KeyVault/CliClients/hooks/useCliAgents.ts @@ -2,7 +2,7 @@ * Hook for fetching CLI agents and performing install/uninstall/detect actions. */ import { useSetAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { loadAvailableAgents } from "@src/api/services/availableAgents"; @@ -10,6 +10,7 @@ import { autoDetectKey } from "@src/api/services/keyValidation"; import type { ModelType } from "@src/api/types/keys"; import Message from "@src/components/Message"; import type { AgentAction, AvailableAgent } from "@src/config/cliAgents"; +import { useAsyncResource } from "@src/hooks/async"; import { TerminalService } from "@src/services/terminal/TerminalService"; import { invalidateDepsAtom } from "@src/store/platform/systemDepsAtom"; @@ -18,41 +19,29 @@ export interface UseCliAgentsOptions { enabled?: boolean; } +const EMPTY_CLI_AGENTS: AvailableAgent[] = []; + export function useCliAgents({ enabled = true }: UseCliAgentsOptions = {}) { const { t } = useTranslation("settings"); - const [agents, setAgents] = useState([]); - // Start false so remounts triggered by `enabled` flips (e.g. the - // Integrations models tab toggling on navigation) don't paint a - // spinner before the IPC begins. `fetchAgents` below flips it true - // for the actual fetch window. - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const [actionMap, setActionMap] = useState>({}); const executeInTerminal = TerminalService.execute; const invalidateDeps = useSetAtom(invalidateDepsAtom); - const fetchAgents = useCallback(async () => { - setLoading(true); - setError(null); - try { - const raw = await loadAvailableAgents(); - const sorted = [...raw].sort((agentA, agentB) => { - const installedDiff = - Number(agentB.installed) - Number(agentA.installed); - if (installedDiff !== 0) return installedDiff; - return agentA.displayName.localeCompare(agentB.displayName); - }); - setAgents(sorted); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } + const loadAgents = useCallback(async () => { + const raw = await loadAvailableAgents(); + return [...raw].sort((agentA, agentB) => { + const installedDiff = Number(agentB.installed) - Number(agentA.installed); + if (installedDiff !== 0) return installedDiff; + return agentA.displayName.localeCompare(agentB.displayName); + }); }, []); - - useEffect(() => { - if (enabled) fetchAgents(); - }, [enabled, fetchAgents]); + const resource = useAsyncResource({ + enabled, + fetcher: loadAgents, + initialData: EMPTY_CLI_AGENTS, + scopeKey: enabled ? "cli-agents" : null, + }); + const fetchAgents = resource.refresh; const handleInstall = useCallback( async (agentName: string, installCmd?: string) => { @@ -130,9 +119,9 @@ export function useCliAgents({ enabled = true }: UseCliAgentsOptions = {}) { ); return { - agents, - loading, - error, + agents: resource.data, + loading: resource.loading, + error: resource.error, actionMap, fetchAgents, handleInstall, diff --git a/src/modules/MainApp/Integrations/RulesMemoryEvolution/Memory/useWorkspaceMemoryStatus.ts b/src/modules/MainApp/Integrations/RulesMemoryEvolution/Memory/useWorkspaceMemoryStatus.ts index fe6a0fec4c..3ea8aee21e 100644 --- a/src/modules/MainApp/Integrations/RulesMemoryEvolution/Memory/useWorkspaceMemoryStatus.ts +++ b/src/modules/MainApp/Integrations/RulesMemoryEvolution/Memory/useWorkspaceMemoryStatus.ts @@ -13,10 +13,11 @@ * (`~/.orgii/personal/workspace/`) regardless of the active folder. */ import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { rpc } from "@src/api/tauri/rpc"; import type { WorkspaceMemoryStatus } from "@src/api/tauri/rpc/schemas/workspaceMemory"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { activeFolderAtom } from "@src/store/workspace/derived"; @@ -30,76 +31,51 @@ interface ResolvedWorkspace { function useWorkspacePath(scope: WorkspaceMemoryScope): ResolvedWorkspace { const activeFolder = useAtomValue(activeFolderAtom); - const [personalWs, setPersonalWs] = useState(null); + const fetchPersonalWorkspace = useCallback(async () => { + try { + return await rpc.agentOrgs.memory.personalWorkspace(); + } catch (error) { + log.warn( + "[useWorkspaceMemoryStatus] project_personal_workspace failed:", + error + ); + throw error; + } + }, []); + const personalWorkspace = useAsyncResource({ + enabled: scope === "personal", + fetcher: fetchPersonalWorkspace, + initialData: null, + scopeKey: scope === "personal" ? "personal-workspace" : null, + }); - useEffect(() => { - let cancelled = false; - if (scope !== "personal") return undefined; - rpc.agentOrgs.memory - .personalWorkspace() - .then((path) => { - if (!cancelled) setPersonalWs(path); - }) - .catch((err: unknown) => { - log.warn( - "[useWorkspaceMemoryStatus] project_personal_workspace failed:", - err - ); - }); - return () => { - cancelled = true; - }; - }, [scope]); - - if (scope === "personal") return { path: personalWs }; + if (scope === "personal") return { path: personalWorkspace.data }; return { path: activeFolder?.path ?? null }; } -function fetchStatus( - workspace: string, - onResult: (result: WorkspaceMemoryStatus) => void, - onDone: () => void, - signal: { cancelled: boolean } -): void { - rpc.workspaceMemory - .status({ workspace }) - .then((result: WorkspaceMemoryStatus) => { - if (!signal.cancelled) onResult(result); - }) - .catch((err: unknown) => { - log.warn("[WorkspaceMemoryStatus] fetch failed:", err); - }) - .finally(() => { - if (!signal.cancelled) onDone(); - }); -} - export function useWorkspaceMemoryStatus( scope: WorkspaceMemoryScope = "workspace" ) { const { path: workspace } = useWorkspacePath(scope); - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(false); - - const refresh = useCallback(() => { - if (!workspace) return; - setLoading(true); - const signal = { cancelled: false }; - fetchStatus(workspace, setStatus, () => setLoading(false), signal); - }, [workspace]); - - useEffect(() => { - if (!workspace) return; - const signal = { cancelled: false }; - const timer = setTimeout(() => { - setLoading(true); - fetchStatus(workspace, setStatus, () => setLoading(false), signal); - }, 0); - return () => { - signal.cancelled = true; - clearTimeout(timer); - }; - }, [workspace]); + const fetchStatus = useCallback(async (workspacePath: string) => { + try { + return await rpc.workspaceMemory.status({ workspace: workspacePath }); + } catch (error) { + log.warn("[WorkspaceMemoryStatus] fetch failed:", error); + throw error; + } + }, []); + const statusResource = useAsyncResource({ + enabled: Boolean(workspace), + fetcher: fetchStatus, + initialData: null, + scopeKey: workspace, + }); - return { status, loading, workspace, refresh }; + return { + status: statusResource.data, + loading: statusResource.loading, + workspace, + refresh: statusResource.refresh, + }; } diff --git a/src/modules/MainApp/Integrations/hooks/lsp/useLspGlobalConfig.ts b/src/modules/MainApp/Integrations/hooks/lsp/useLspGlobalConfig.ts index a8d44266de..2f4b8fd524 100644 --- a/src/modules/MainApp/Integrations/hooks/lsp/useLspGlobalConfig.ts +++ b/src/modules/MainApp/Integrations/hooks/lsp/useLspGlobalConfig.ts @@ -8,8 +8,9 @@ * the corresponding UI lands. */ import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; const log = createLogger("useLspGlobalConfig"); @@ -47,42 +48,38 @@ const DEFAULT_CONFIG: GlobalLspConfig = { }; export function useLspGlobalConfig() { - const [config, setConfig] = useState(DEFAULT_CONFIG); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const loadConfig = useCallback(async () => { - setIsLoading(true); - setError(null); try { - const result = await invoke("lsp_get_global_config"); - setConfig(result); + return await invoke("lsp_get_global_config"); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); log.error("[useLspGlobalConfig] Failed to load config:", err); - } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - loadConfig(); - }, [loadConfig]); - - const setAutoInstall = useCallback(async (enabled: boolean) => { - try { - await invoke("lsp_set_auto_install", { enabled }); - setConfig((prev) => ({ ...prev, autoInstall: enabled })); - } catch (err) { - log.error("[useLspGlobalConfig] Failed to set auto-install:", err); throw err; } }, []); + const configResource = useAsyncResource({ + fetcher: loadConfig, + initialData: DEFAULT_CONFIG, + scopeKey: "lsp-global-config", + }); + const setConfig = configResource.setData; + + const setAutoInstall = useCallback( + async (enabled: boolean) => { + try { + await invoke("lsp_set_auto_install", { enabled }); + setConfig((prev) => ({ ...prev, autoInstall: enabled })); + } catch (err) { + log.error("[useLspGlobalConfig] Failed to set auto-install:", err); + throw err; + } + }, + [setConfig] + ); return { - config, - isLoading, - error, + config: configResource.data, + isLoading: configResource.loading, + error: configResource.error, setAutoInstall, }; } diff --git a/src/modules/MainApp/Integrations/hooks/lsp/useLspServerLog.ts b/src/modules/MainApp/Integrations/hooks/lsp/useLspServerLog.ts index 6c8eb7b27a..1fc38f33e7 100644 --- a/src/modules/MainApp/Integrations/hooks/lsp/useLspServerLog.ts +++ b/src/modules/MainApp/Integrations/hooks/lsp/useLspServerLog.ts @@ -13,12 +13,13 @@ * realtime tailing we can reuse the existing code-editor WebSocket; * for now this is the simplest correct path. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; +import { useVisibilityPolledData } from "@src/hooks/async"; import type { LspLogLine } from "@src/modules/MainApp/Integrations/DevTools/LanguageServersPage/types"; -import { startVisibilityAwarePoller } from "@src/shared/scheduling/visibilityAwarePoller"; const POLL_INTERVAL_MS = 1500; +const EMPTY_LOG: LspLogLine[] = []; async function tauriInvoke( command: string, @@ -44,49 +45,18 @@ export function useLspServerLog({ language, enabled, }: UseLspServerLogOptions): UseLspServerLogResult { - const [log, setLog] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const cancelledRef = useRef(false); - - const fetchOnce = useCallback(async () => { - if (!language) { - setLog([]); - return; - } - setIsLoading(true); - try { - const next = await tauriInvoke("lsp_get_server_log", { - language, - }); - if (cancelledRef.current) return; - setLog(next); - setError(null); - } catch (err) { - if (cancelledRef.current) return; - setError(err instanceof Error ? err.message : String(err)); - } finally { - if (!cancelledRef.current) setIsLoading(false); - } - }, [language]); - - useEffect(() => { - cancelledRef.current = false; - if (!enabled || !language) { - setLog([]); - return undefined; - } - - const stopPolling = startVisibilityAwarePoller( - document, - fetchOnce, - POLL_INTERVAL_MS - ); - return () => { - cancelledRef.current = true; - stopPolling(); - }; - }, [enabled, language, fetchOnce]); - - return { log, isLoading, error, refresh: fetchOnce }; + const fetchLog = useCallback( + (scope: string) => + tauriInvoke("lsp_get_server_log", { language: scope }), + [] + ); + const { data, loading, error, refresh } = useVisibilityPolledData({ + enabled: enabled && Boolean(language), + fetcher: fetchLog, + initialData: EMPTY_LOG, + intervalMs: POLL_INTERVAL_MS, + scopeKey: language, + }); + + return { log: data, isLoading: loading, error, refresh }; } diff --git a/src/modules/MainApp/Integrations/hooks/useChannelState.ts b/src/modules/MainApp/Integrations/hooks/useChannelState.ts index b580ef18a4..f572ee0a2a 100644 --- a/src/modules/MainApp/Integrations/hooks/useChannelState.ts +++ b/src/modules/MainApp/Integrations/hooks/useChannelState.ts @@ -5,7 +5,7 @@ * "Add channel" wizard open-state lives in the URL via * {@link useWizardParam} (`?wizard=channel-add`). */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -15,6 +15,7 @@ import { } from "@src/api/http/integrations"; import { toggleChannel } from "@src/api/tauri/agent"; import { WIZARD_IDS, buildWizardPath } from "@src/config/mainAppPaths"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { useWizardParam } from "@src/hooks/navigation"; import type { WizardCategory } from "@src/scaffold/WizardSystem/variants/Channel/channelWizardTypes"; @@ -41,6 +42,7 @@ import type { } from "../Connections/Channels"; const log = createLogger("integrations"); +const EMPTY_SYNC_CONNECTIONS: SyncConnection[] = []; function resolveConnectionStatus( enabled: boolean, @@ -90,54 +92,20 @@ export function useChannelState(options: UseChannelStateOptions = {}) { const [channelProbing, setChannelProbing] = useState(false); const [channelProbeResult, setChannelProbeResult] = useState(null); - const [projectConnections, setProjectConnections] = useState< - SyncConnection[] - >([]); - const [projectConnectionsLoading, setProjectConnectionsLoading] = - useState(false); - const [projectConnectionsError, setProjectConnectionsError] = useState< - string | null - >(null); const probeIdRef = useRef(0); - const refreshProjectConnections = useCallback(async () => { - setProjectConnectionsLoading(true); - setProjectConnectionsError(null); - try { - const connections = await syncConnectionsApi.list(); - setProjectConnections(connections); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setProjectConnectionsError(message); - throw err; - } finally { - setProjectConnectionsLoading(false); - } - }, []); - - useEffect(() => { - let cancelled = false; - setProjectConnectionsLoading(true); - setProjectConnectionsError(null); - syncConnectionsApi - .list() - .then((connections) => { - if (!cancelled) setProjectConnections(connections); - }) - .catch((err) => { - if (!cancelled) { - setProjectConnectionsError( - err instanceof Error ? err.message : String(err) - ); - } - }) - .finally(() => { - if (!cancelled) setProjectConnectionsLoading(false); - }); - return () => { - cancelled = true; - }; + const loadProjectConnections = useCallback(() => { + return syncConnectionsApi.list(); }, []); + const projectConnectionsResource = useAsyncResource({ + fetcher: loadProjectConnections, + initialData: EMPTY_SYNC_CONNECTIONS, + scopeKey: "project-connections", + }); + const projectConnections = projectConnectionsResource.data; + const projectConnectionsLoading = projectConnectionsResource.loading; + const projectConnectionsError = projectConnectionsResource.error; + const refreshProjectConnections = projectConnectionsResource.refresh; // ── Derived data ── const channelInstances = useMemo(() => { diff --git a/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts b/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts index f763338136..49d0e67753 100644 --- a/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts +++ b/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo } from "react"; import { getGitRemotes } from "@src/api/http/git/remotes"; import { @@ -10,6 +10,10 @@ import type { OpenPRItem, PullRequestListState, } from "@src/api/tauri/github"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { coalesceGitHubListRequest, getCachedIssues, @@ -87,6 +91,28 @@ export const EMPTY_REPO_PRS: RepoPrState = { closedError: null, }; +interface GitHubWorkItemsLoadData { + loadError: string | null; + repoIssueMap: Record; + repoPrMap: Record; + repoSources: GitHubRepoSource[]; +} + +interface GitHubWorkItemsLoadRequest { + issueStates: GitHubIssuePageState[]; + prStates: PullRequestListState[]; + refreshNonce: number; + repos: Repo[]; + scope: Extract; +} + +const EMPTY_GITHUB_WORK_ITEMS_LOAD_DATA: GitHubWorkItemsLoadData = { + loadError: null, + repoIssueMap: {}, + repoPrMap: {}, + repoSources: [], +}; + export function getRepoIssueMapKey(source: GitHubRepoSource): string { return source.repoFullName; } @@ -242,137 +268,148 @@ export function useGitHubWorkItemsLoadLifecycle({ prStates: PullRequestListState[]; refreshNonce: number; }) { - const [repoSources, setRepoSources] = useState([]); - const [repoIssueMap, setRepoIssueMap] = useState< - Record - >({}); - const [repoPrMap, setRepoPrMap] = useState>({}); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const handledRefreshNonceRef = useRef(0); const gitRepos = useMemo( () => repos.filter((repo) => repo.kind === REPO_KIND.GIT && repo.path), [repos] ); - - useEffect(() => { - let cancelled = false; - const forceRefresh = refreshNonce !== handledRefreshNonceRef.current; - handledRefreshNonceRef.current = refreshNonce; - void (async () => { - setLoading(true); - setLoadError(null); + const scopeKey = JSON.stringify({ + issueStates, + prStates, + refreshNonce, + repos: gitRepos, + scope, + } satisfies GitHubWorkItemsLoadRequest); + const loadWorkItems = useCallback( + async ( + serializedRequest: string, + context: AsyncResourceFetchContext + ) => { + const request = JSON.parse( + serializedRequest + ) as GitHubWorkItemsLoadRequest; const resolvedSources = ( - await Promise.all(gitRepos.map(resolveGitHubRepoSource)) + await Promise.all(request.repos.map(resolveGitHubRepoSource)) ).filter((source): source is GitHubRepoSource => Boolean(source)); - if (cancelled) return; - setRepoSources(resolvedSources); - setRepoIssueMap( - scope === "issue" - ? Object.fromEntries( - resolvedSources.map((source) => [ - getRepoIssueMapKey(source), - getCachedRepoIssues(source), - ]) - ) - : {} - ); - setRepoPrMap( - scope === "pr" - ? Object.fromEntries( - resolvedSources.map((source) => [ - getRepoIssueMapKey(source), - getCachedRepoPrs(source), - ]) - ) - : {} - ); - if (resolvedSources.length === 0) { - setLoading(false); - return; - } + const cachedData: GitHubWorkItemsLoadData = { + loadError: null, + repoIssueMap: + request.scope === "issue" + ? Object.fromEntries( + resolvedSources.map((source) => [ + getRepoIssueMapKey(source), + getCachedRepoIssues(source), + ]) + ) + : {}, + repoPrMap: + request.scope === "pr" + ? Object.fromEntries( + resolvedSources.map((source) => [ + getRepoIssueMapKey(source), + getCachedRepoPrs(source), + ]) + ) + : {}, + repoSources: resolvedSources, + }; + context.publish(cachedData, { keepLoading: true }); + if (resolvedSources.length === 0) return cachedData; + + const forceRefresh = + context.cause === "refresh" || request.refreshNonce > 0; const [issueResults, prResults] = await Promise.all([ - scope === "issue" + request.scope === "issue" ? Promise.all( resolvedSources.map((source) => - loadRepoIssues(source, issueStates, forceRefresh) + loadRepoIssues(source, request.issueStates, forceRefresh) ) ) : Promise.resolve([]), - scope === "pr" + request.scope === "pr" ? Promise.all( resolvedSources.flatMap((source) => - prStates.map((state) => + request.prStates.map((state) => loadRepoPrs(source, state, forceRefresh) ) ) ) : Promise.resolve([]), ]); - if (cancelled) return; - if (scope === "issue") { - setRepoIssueMap( - Object.fromEntries( - issueResults.map(({ source, error: _error, ...state }) => [ - getRepoIssueMapKey(source), - state, - ]) - ) - ); - } else { - setRepoPrMap((current) => { - const next = { ...current }; - for (const result of prResults) { - const key = getRepoIssueMapKey(result.source); - const currentState = next[key] ?? EMPTY_REPO_PRS; - next[key] = - result.state === "open" - ? { - ...currentState, - openPrs: result.prs, - openLoaded: result.loaded, - openError: result.error, - } - : { - ...currentState, - closedPrs: result.prs, - closedLoaded: result.loaded, - closedError: result.error, - }; - } - return next; - }); + + const repoIssueMap = + request.scope === "issue" + ? Object.fromEntries( + issueResults.map(({ source, error: _error, ...state }) => [ + getRepoIssueMapKey(source), + state, + ]) + ) + : {}; + const repoPrMap: Record = {}; + if (request.scope === "pr") { + for (const result of prResults) { + const key = getRepoIssueMapKey(result.source); + const currentState = repoPrMap[key] ?? EMPTY_REPO_PRS; + repoPrMap[key] = + result.state === "open" + ? { + ...currentState, + openPrs: result.prs, + openLoaded: result.loaded, + openError: result.error, + } + : { + ...currentState, + closedPrs: result.prs, + closedLoaded: result.loaded, + closedError: result.error, + }; + } } - setLoadError( - issueResults.find((result) => result.error)?.error ?? + return { + loadError: + issueResults.find((result) => result.error)?.error ?? prResults.find((result) => result.error)?.error ?? - null - ); - setLoading(false); - })(); - return () => { - cancelled = true; - }; - }, [gitRepos, issueStates, prStates, refreshNonce, scope]); + null, + repoIssueMap, + repoPrMap, + repoSources: resolvedSources, + }; + }, + [] + ); + const resource = useAsyncResource({ + fetcher: loadWorkItems, + initialData: EMPTY_GITHUB_WORK_ITEMS_LOAD_DATA, + scopeKey, + }); + const setLoadData = resource.setData; const updateIssueMap = useCallback( ( update: ( current: Record ) => Record - ) => setRepoIssueMap(update), - [] + ) => + setLoadData((current) => ({ + ...current, + repoIssueMap: update(current.repoIssueMap), + })), + [setLoadData] + ); + const setListError = useCallback( + (error: string | null) => { + setLoadData((current) => ({ ...current, loadError: error })); + }, + [setLoadData] ); - const setListError = useCallback((error: string | null) => { - setLoadError(error); - }, []); return { - repoSources, - repoIssueMap, - repoPrMap, - loading, - loadError, + repoSources: resource.data.repoSources, + repoIssueMap: resource.data.repoIssueMap, + repoPrMap: resource.data.repoPrMap, + loading: resource.loading, + loadError: resource.error ?? resource.data.loadError, updateIssueMap, setListError, }; diff --git a/src/modules/ProjectManager/LinearProjects/useLinearIndexData.tsx b/src/modules/ProjectManager/LinearProjects/useLinearIndexData.tsx index 2886af712f..7226ccec13 100644 --- a/src/modules/ProjectManager/LinearProjects/useLinearIndexData.tsx +++ b/src/modules/ProjectManager/LinearProjects/useLinearIndexData.tsx @@ -8,7 +8,7 @@ * - Expose groupMode state and derived Project groupings for the projects list */ import { CalendarClock, Circle, Flag } from "lucide-react"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -18,6 +18,10 @@ import { } from "@src/api/http/integrations"; import type { LinearProjectSummary } from "@src/api/http/integrations"; import type { SelectOption } from "@src/components/Select"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import type { StatusFilterType } from "@src/modules/ProjectManager/WorkItems/types"; import { countWorkItemsByStatus, @@ -75,6 +79,22 @@ const TARGET_DATE_GROUPS = [ type TargetDateGroup = (typeof TARGET_DATE_GROUPS)[number]; +interface LinearIndexResource { + projects: LinearProjectSummary[]; + workItems: WorkItem[]; +} + +interface LinearIndexScope { + connectionId: string; + surface: "projects" | "work-items"; + teamId?: string; +} + +const EMPTY_LINEAR_INDEX: LinearIndexResource = { + projects: [], + workItems: [], +}; + // ============================================ // Helpers (pure) // ============================================ @@ -164,132 +184,108 @@ export function useLinearIndexData({ const { t } = useTranslation(["projects", "common"]); // ---- Connection discovery ---- - const [defaultConnectionId, setDefaultConnectionId] = useState( - null - ); - const [loadingConnections, setLoadingConnections] = useState(false); - const [connectionLoadError, setConnectionLoadError] = useState( - null - ); - - useEffect(() => { - if (connectionId) return; - - let cancelled = false; - setLoadingConnections(true); - setConnectionLoadError(null); - syncConnectionsApi - .list() - .then((connections) => { - if (cancelled) return; - const linearConnection = connections.find( - (connection) => connection.adapter_id === STORY_SYNC_ADAPTER.LINEAR - ); - setDefaultConnectionId(linearConnection?.id ?? null); - }) - .catch((error: unknown) => { - if (cancelled) return; - const message = error instanceof Error ? error.message : String(error); - setDefaultConnectionId(null); - setConnectionLoadError(message); - }) - .finally(() => { - if (!cancelled) setLoadingConnections(false); - }); - - return () => { - cancelled = true; - }; - }, [connectionId]); + const discoverDefaultConnection = useCallback(async () => { + const connections = await syncConnectionsApi.list(); + return ( + connections.find( + (connection) => connection.adapter_id === STORY_SYNC_ADAPTER.LINEAR + )?.id ?? null + ); + }, []); + const connectionResource = useAsyncResource({ + enabled: !connectionId, + fetcher: discoverDefaultConnection, + initialData: null, + scopeKey: connectionId ? null : "linear-default-connection", + }); + const defaultConnectionId = connectionResource.data; + const loadingConnections = connectionResource.loading; + const connectionLoadError = connectionResource.error; const effectiveConnectionId = connectionId ?? defaultConnectionId ?? undefined; // ---- Index data (projects + optional issues) ---- - const [indexProjects, setIndexProjects] = useState( - [] - ); - const [indexWorkItems, setIndexWorkItems] = useState([]); - const [indexLoading, setIndexLoading] = useState(false); - const [indexLoaded, setIndexLoaded] = useState(false); - const [indexError, setIndexError] = useState(null); const [indexStatusFilter, setIndexStatusFilter] = useState("all"); - const loadIndexData = useCallback( + const fetchIndexData = useCallback( async ( - cancelled?: () => boolean, - options: { forceRefresh?: boolean } = {} + serializedScope: string, + context: AsyncResourceFetchContext ) => { - if (!effectiveConnectionId || projectId) return; - - setIndexLoading(true); - setIndexLoaded(false); - setIndexError(null); + const scope = JSON.parse(serializedScope) as LinearIndexScope; + const forceRefresh = context.cause === "refresh"; try { const projectsResult = await cachedLinearProjectsApi.listProjects( - effectiveConnectionId, - { forceRefresh: options.forceRefresh } + scope.connectionId, + { forceRefresh } ); - if (cancelled?.()) return; - const visibleProjects = teamId + const projects = scope.teamId ? projectsResult.projects.filter((linearProject) => - linearProject.teams.some((team) => team.id === teamId) + linearProject.teams.some((team) => team.id === scope.teamId) ) : projectsResult.projects; - setIndexProjects(visibleProjects); - if (surface === "work-items") { + if (scope.surface === "work-items") { const issueResults = await Promise.all( - visibleProjects.map((linearProject) => + projects.map((linearProject) => cachedLinearProjectsApi.listProjectIssues( - effectiveConnectionId, + scope.connectionId, linearProject.id, - { forceRefresh: options.forceRefresh } + { forceRefresh } ) ) ); - if (cancelled?.()) return; - setIndexWorkItems( - issueResults.flatMap((result, resultIndex) => { - const linearProject = visibleProjects[resultIndex]; + return { + projects, + workItems: issueResults.flatMap((result, resultIndex) => { + const linearProject = projects[resultIndex]; return result.issues.map((issue) => linearIssueToWorkItem(issue, linearProject) ); - }) - ); - return; + }), + }; } - setIndexWorkItems([]); + return { projects, workItems: [] }; } catch (error: unknown) { - if (cancelled?.()) return; - setIndexProjects([]); - setIndexWorkItems([]); - setIndexError( + throw new Error( errorMessage(error, t("linearProjects.errors.loadProjects")) ); - } finally { - if (!cancelled?.()) { - setIndexLoaded(true); - setIndexLoading(false); - } } }, - [effectiveConnectionId, projectId, surface, teamId, t] + [t] ); - useEffect(() => { - let cancelled = false; - void loadIndexData(() => cancelled); - return () => { - cancelled = true; - }; - }, [loadIndexData]); - + const indexScopeKey = useMemo( + () => + effectiveConnectionId && !projectId + ? JSON.stringify({ + connectionId: effectiveConnectionId, + surface, + teamId, + } satisfies LinearIndexScope) + : null, + [effectiveConnectionId, projectId, surface, teamId] + ); + const indexResource = useAsyncResource({ + enabled: Boolean(indexScopeKey), + fetcher: fetchIndexData, + initialData: EMPTY_LINEAR_INDEX, + scopeKey: indexScopeKey, + }); + const indexProjects = indexResource.data.projects; + const indexWorkItems = indexResource.data.workItems; + const indexLoading = indexResource.loading; + const indexLoaded = + indexResource.status === "ready" || indexResource.status === "error"; + const indexError = indexResource.error; + const refreshIndex = indexResource.refresh; + const setIndexData = indexResource.setData; const handleIndexRefresh = useCallback(() => { - void loadIndexData(undefined, { forceRefresh: true }); - }, [loadIndexData]); + void refreshIndex(); + }, [refreshIndex]); const [indexUpdateError, setIndexUpdateError] = useState(null); @@ -314,23 +310,24 @@ export function useLinearIndexData({ updatedIssue.project.id ); } - setIndexWorkItems((currentItems) => - currentItems.map((currentItem) => { + setIndexData((current) => ({ + ...current, + workItems: current.workItems.map((currentItem) => { if (currentItem.session_id !== workItemId) return currentItem; const parentProject = indexProjects.find( (linearProject) => linearProject.id === currentItem.project?.id ); if (!parentProject) return currentItem; return linearIssueToWorkItem(updatedIssue, parentProject); - }) - ); + }), + })); } catch (err) { setIndexUpdateError( errorMessage(err, t("linearProjects.errors.updateIssue")) ); } }, - [effectiveConnectionId, indexProjects, t] + [effectiveConnectionId, indexProjects, setIndexData, t] ); // ---- Index work item derived views ---- diff --git a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectOrgCatalogData.ts b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectOrgCatalogData.ts index 1e778f296e..1e6b233492 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectOrgCatalogData.ts +++ b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectOrgCatalogData.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { PROJECT_ORG_SYNC_PROVIDER, projectApi } from "@src/api/http/project"; import type { @@ -6,6 +6,7 @@ import type { ProjectData, ProjectOrg, } from "@src/api/http/project"; +import { useAsyncResource } from "@src/hooks/async"; import type { Label } from "@src/types/core/shared"; interface LabelsByProject { @@ -33,46 +34,72 @@ function mergeLabels(projectLabels: LabelsByProject[]): Label[] { ); } +interface ProjectOrgCatalogResource { + labelsByProject: LabelsByProject[]; + org: ProjectOrg | null; + projects: ProjectData[]; +} + +const EMPTY_PROJECT_ORG_CATALOG: ProjectOrgCatalogResource = { + labelsByProject: [], + org: null, + projects: [], +}; + export function useProjectOrgCatalogData(orgId: string) { - const [org, setOrg] = useState(null); - const [projects, setProjects] = useState([]); - const [labelsByProject, setLabelsByProject] = useState([]); - const [folderPath, setFolderPath] = useState(""); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - - const loadOrgCatalog = useCallback(async () => { - setLoading(true); - setLoadError(null); - try { - const [allOrgs, orgProjects] = await Promise.all([ - projectApi.readOrgs(), - projectApi.readProjects({ orgId }), - ]); - const currentOrg = allOrgs.find((entry) => entry.id === orgId); - if (!currentOrg) { - throw new Error(`Project org not found: ${orgId}`); - } - const nextLabelsByProject = await Promise.all( - orgProjects.map(async (project) => ({ - projectSlug: project.slug, - labels: (await projectApi.readLabels(project.slug)).labels, - })) - ); - setOrg(currentOrg); - setProjects(orgProjects); - setLabelsByProject(nextLabelsByProject); - setFolderPath(parseGitFolderPath(currentOrg)); - } catch (err) { - setLoadError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); + const [folderDraft, setFolderDraft] = useState<{ + baseValue: string; + orgId: string; + value: string; + } | null>(null); + + const fetchOrgCatalog = useCallback(async (scopeOrgId: string) => { + const [allOrgs, projects] = await Promise.all([ + projectApi.readOrgs(), + projectApi.readProjects({ orgId: scopeOrgId }), + ]); + const org = allOrgs.find((entry) => entry.id === scopeOrgId); + if (!org) { + throw new Error(`Project org not found: ${scopeOrgId}`); } - }, [orgId]); + const labelsByProject = await Promise.all( + projects.map(async (project) => ({ + projectSlug: project.slug, + labels: (await projectApi.readLabels(project.slug)).labels, + })) + ); + return { labelsByProject, org, projects }; + }, []); - useEffect(() => { - void loadOrgCatalog(); - }, [loadOrgCatalog]); + const resource = useAsyncResource({ + enabled: Boolean(orgId), + fetcher: fetchOrgCatalog, + initialData: EMPTY_PROJECT_ORG_CATALOG, + scopeKey: orgId || null, + }); + const { + data: catalog, + error: loadError, + loading, + refresh: reload, + setData: setCatalog, + } = resource; + const { labelsByProject, org, projects } = catalog; + const storedFolderPath = parseGitFolderPath(org); + const folderPath = + folderDraft?.orgId === orgId && folderDraft.baseValue === storedFolderPath + ? folderDraft.value + : storedFolderPath; + const setFolderPath = useCallback( + (value: string) => { + setFolderDraft({ + baseValue: storedFolderPath, + orgId, + value, + }); + }, + [orgId, storedFolderPath] + ); const labels = useMemo(() => mergeLabels(labelsByProject), [labelsByProject]); @@ -84,14 +111,15 @@ export function useProjectOrgCatalogData(orgId: string) { projectApi.writeLabels(project.slug, { labels: updatedLabels }) ) ); - setLabelsByProject( - projects.map((project) => ({ + setCatalog((current) => ({ + ...current, + labelsByProject: projects.map((project) => ({ projectSlug: project.slug, labels: updatedLabels, - })) - ); + })), + })); }, - [projects] + [projects, setCatalog] ); const handleConfigureGitFolder = useCallback(async () => { @@ -99,22 +127,25 @@ export function useProjectOrgCatalogData(orgId: string) { org_id: orgId, folder_path: folderPath.trim(), }); - setOrg(configuredOrg); - setFolderPath(parseGitFolderPath(configuredOrg)); - }, [folderPath, orgId]); + setCatalog((current) => ({ ...current, org: configuredOrg })); + const configuredFolderPath = parseGitFolderPath(configuredOrg); + setFolderDraft({ + baseValue: configuredFolderPath, + orgId, + value: configuredFolderPath, + }); + }, [folderPath, orgId, setCatalog]); const handleSyncGitFolder = useCallback(async () => { const result = await projectApi.syncOrgGitFolder({ org_id: orgId }); - await loadOrgCatalog(); + await reload(); return result; - }, [loadOrgCatalog, orgId]); + }, [orgId, reload]); const handleDeleteOrg = useCallback(async () => { await projectApi.deleteOrg(orgId); - setOrg(null); - setProjects([]); - setLabelsByProject([]); - }, [orgId]); + setCatalog(EMPTY_PROJECT_ORG_CATALOG); + }, [orgId, setCatalog]); const isGitFolderSynced = org?.sync_provider === PROJECT_ORG_SYNC_PROVIDER.GIT_FOLDER; @@ -132,6 +163,6 @@ export function useProjectOrgCatalogData(orgId: string) { handleConfigureGitFolder, handleSyncGitFolder, handleDeleteOrg, - reload: loadOrgCatalog, + reload, }; } diff --git a/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts b/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts index 8ac647c9d4..8af8ea5b10 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { type MemberEntry, projectApi } from "@src/api/http/project"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { useProjectDataChanged } from "@src/hooks/project"; import type { ProjectData } from "@src/modules/ProjectManager/shared"; @@ -13,8 +14,18 @@ import type { Label, Person } from "@src/types/core/shared"; import type { UseProjectDataOptions, UseProjectDataReturn } from "./types"; import { useProjectDataFile } from "./useProjectDataFile"; +import type { FetchFromFilesResult } from "./useProjectDataFile"; const log = createLogger("useProjectData"); +const AUTO_PROJECT_SCOPE = "__auto_project__"; +const EMPTY_PROJECT_DATA: FetchFromFilesResult = { + allProjects: [], + autoSelectedId: null, + labels: [], + members: [], + project: null, + rawMembers: [], +}; export function useProjectData( options: UseProjectDataOptions = {} @@ -24,212 +35,134 @@ export function useProjectData( autoLoad = true, isActive = true, } = options; - - const [project, setProject] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState( initialProjectId || null ); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const { fetchFromFiles, updateProjectFile } = useProjectDataFile(); + const fetchProjectData = useCallback( + async (scopeKey: string) => { + try { + return await fetchFromFiles( + scopeKey === AUTO_PROJECT_SCOPE ? null : scopeKey + ); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to load project from store"; + log.error("[useProjectData] Load error:", error); + throw new Error(message); + } + }, + [fetchFromFiles] + ); + const { + data, + error, + loading, + refresh: loadFromFiles, + setData: setProjectData, + } = useAsyncResource({ + autoLoad, + fetcher: fetchProjectData, + initialData: EMPTY_PROJECT_DATA, + scopeKey: selectedProjectId ?? AUTO_PROJECT_SCOPE, + }); + + const project = data.project; const projectRef = useRef(project); projectRef.current = project; + const availableMembers: Person[] = data.members; + const availableLabels: Label[] = data.labels; - const [storeMembers, setStoreMembers] = useState([]); - const [storeLabels, setStoreLabels] = useState([]); - const [storeProjects, setStoreProjects] = useState< - { id: string; name: string }[] - >([]); - const [rawMembers, setRawMembers] = useState([]); - const [rawLabels, setRawLabels] = useState([]); - - const file = useProjectDataFile(); - - const availableMembers = storeMembers; - const availableLabels = storeLabels; + useEffect(() => { + if (initialProjectId && initialProjectId !== selectedProjectId) { + setSelectedProjectId(initialProjectId); + } + // selectedProjectId is deliberately omitted: this effect mirrors prop + // changes into the local selection without undoing a user selection. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialProjectId]); - const loadFromFiles = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await file.fetchFromFiles(selectedProjectId); - setProject(result.project); - setStoreMembers(result.members); - setStoreLabels(result.labels); - setStoreProjects(result.allProjects); - setRawMembers(result.rawMembers); - setRawLabels(result.labels); - if (result.autoSelectedId) { - setSelectedProjectId(result.autoSelectedId); - } - } catch (err) { - const message = - err instanceof Error - ? err.message - : "Failed to load project from store"; - setError(message); - log.error("[useProjectData] Load error:", err); - } finally { - setLoading(false); + useEffect(() => { + if (data.autoSelectedId && data.autoSelectedId !== selectedProjectId) { + setSelectedProjectId(data.autoSelectedId); } - }, [file, selectedProjectId]); + }, [data.autoSelectedId, selectedProjectId]); const updateProject = useCallback( async (updates: Partial): Promise => { if (!selectedProjectId) return false; - setProject((prev: ProjectData | null) => - prev ? { ...prev, ...updates } : prev - ); + setProjectData((current) => ({ + ...current, + project: current.project ? { ...current.project, ...updates } : null, + })); try { const currentProject = projectRef.current; if (!currentProject) return false; const merged = { ...currentProject, ...updates }; - await file.updateProjectFile(merged, updates); + await updateProjectFile(merged, updates); return true; - } catch (err) { - log.error("[useProjectData] Update error:", err); + } catch (error) { + log.error("[useProjectData] Update error:", error); await loadFromFiles(); return false; } }, - [selectedProjectId, file, loadFromFiles] + [loadFromFiles, selectedProjectId, setProjectData, updateProjectFile] + ); + + const updateMembers = useCallback( + async (updatedMembers: MemberEntry[]) => { + const slug = projectRef.current?.slug; + if (!slug) return; + setProjectData((current) => ({ + ...current, + members: updatedMembers + .filter((member) => member.active) + .map((member) => ({ + id: member.id, + name: member.name, + email: member.email, + avatar: member.avatar, + })), + rawMembers: updatedMembers, + })); + await projectApi.writeMembers(slug, { members: updatedMembers }); + }, + [setProjectData] ); - const refresh = useCallback(async () => { - await loadFromFiles(); - }, [loadFromFiles]); + const updateLabels = useCallback( + async (updatedLabels: Label[]) => { + const slug = projectRef.current?.slug; + if (!slug) return; + setProjectData((current) => ({ + ...current, + labels: updatedLabels, + })); + await projectApi.writeLabels(slug, { labels: updatedLabels }); + }, + [setProjectData] + ); const selectProject = useCallback((newProjectId: string) => { setSelectedProjectId(newProjectId); }, []); - const updateMembers = useCallback(async (updatedMembers: MemberEntry[]) => { - const slug = projectRef.current?.slug; - if (!slug) return; - setRawMembers(updatedMembers); - setStoreMembers( - updatedMembers - .filter((member) => member.active) - .map((member) => ({ - id: member.id, - name: member.name, - email: member.email, - avatar: member.avatar, - })) - ); - await projectApi.writeMembers(slug, { - members: updatedMembers, - }); - }, []); - - const updateLabels = useCallback(async (updatedLabels: Label[]) => { - const slug = projectRef.current?.slug; - if (!slug) return; - setRawLabels(updatedLabels); - setStoreLabels(updatedLabels); - await projectApi.writeLabels(slug, { - labels: updatedLabels, - }); - }, []); - - useEffect(() => { - if (initialProjectId && initialProjectId !== selectedProjectId) { - setSelectedProjectId(initialProjectId); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialProjectId]); - - useEffect(() => { - if (!autoLoad) return; - - let cancelled = false; - - const load = async () => { - setLoading(true); - setError(null); - try { - const result = await file.fetchFromFiles(selectedProjectId); - if (cancelled) return; - setProject(result.project); - setStoreMembers(result.members); - setStoreLabels(result.labels); - setStoreProjects(result.allProjects); - setRawMembers(result.rawMembers); - setRawLabels(result.labels); - if (result.autoSelectedId) { - setSelectedProjectId(result.autoSelectedId); - } - } catch (err) { - if (cancelled) return; - const message = - err instanceof Error - ? err.message - : "Failed to load project from store"; - setError(message); - log.error("[useProjectData] Load error:", err); - } finally { - if (!cancelled) setLoading(false); - } - }; - - load(); - - return () => { - cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoLoad]); - - useEffect(() => { - if (!selectedProjectId) return; - let cancelled = false; - - const load = async () => { - setLoading(true); - setError(null); - try { - const result = await file.fetchFromFiles(selectedProjectId); - if (cancelled) return; - setProject(result.project); - setStoreMembers(result.members); - setStoreLabels(result.labels); - setStoreProjects(result.allProjects); - setRawMembers(result.rawMembers); - setRawLabels(result.labels); - } catch (err) { - if (cancelled) return; - const message = - err instanceof Error - ? err.message - : "Failed to load project from store"; - setError(message); - log.error("[useProjectData] Load error:", err); - } finally { - if (!cancelled) setLoading(false); - } - }; - - load(); - - return () => { - cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedProjectId]); - const activeLoadFromFiles = useCallback(() => { if (!isActive) return; - loadFromFiles(); + void loadFromFiles(); }, [isActive, loadFromFiles]); - useProjectDataChanged(activeLoadFromFiles); const wasActiveRef = useRef(isActive); useEffect(() => { if (isActive && !wasActiveRef.current && project !== null) { - loadFromFiles(); + void loadFromFiles(); } wasActiveRef.current = isActive; }, [isActive, loadFromFiles, project]); @@ -241,11 +174,11 @@ export function useProjectData( availableMembers, availableTeams: [], availableLabels, - availableProjects: storeProjects, + availableProjects: data.allProjects, availableMilestones: [], - rawMembers, - rawLabels, - refresh, + rawMembers: data.rawMembers, + rawLabels: data.labels, + refresh: loadFromFiles, updateProject, updateMembers, updateLabels, diff --git a/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectDataFile.ts b/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectDataFile.ts index e945da17d3..12d80a0b3f 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectDataFile.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectDataFile.ts @@ -26,7 +26,7 @@ function normalizeWorkItemPrefix(prefix: string): string { return prefix.trim().toUpperCase(); } -interface FetchFromFilesResult { +export interface FetchFromFilesResult { project: ProjectData | null; allProjects: { id: string; name: string; slug: string }[]; labels: Label[]; diff --git a/src/modules/WorkStation/Browser/hooks/browserDiagnosticsLifecycle.test.ts b/src/modules/WorkStation/Browser/hooks/browserDiagnosticsLifecycle.test.ts index 6f43804531..3fa4196d8e 100644 --- a/src/modules/WorkStation/Browser/hooks/browserDiagnosticsLifecycle.test.ts +++ b/src/modules/WorkStation/Browser/hooks/browserDiagnosticsLifecycle.test.ts @@ -108,17 +108,7 @@ describe("browser diagnostics lifecycle", () => { act(() => { poll = latest!.pollNow(); }); - act(() => { - root.render( - createElement(Harness, { - enabled: false, - sessionId: "session-1", - webviewLabel: "browser-session-1", - pollInterval: 0, - onValue: capture, - }) - ); - }); + act(() => latest!.clearSessionEntries("session-1")); expect(latest!.entries).toEqual([]); await act(async () => { @@ -182,17 +172,7 @@ describe("browser diagnostics lifecycle", () => { act(() => { poll = latest!.pollNow(); }); - act(() => { - root.render( - createElement(Harness, { - enabled: false, - sessionId: "session-1", - webviewLabel: "browser-session-1", - pollInterval: 0, - onValue: capture, - }) - ); - }); + act(() => latest!.clearSessionEntries("session-1")); await act(async () => { request.resolve([ diff --git a/src/modules/WorkStation/Browser/hooks/useBrowserConsole.ts b/src/modules/WorkStation/Browser/hooks/useBrowserConsole.ts index 018d92bd26..355037b2eb 100644 --- a/src/modules/WorkStation/Browser/hooks/useBrowserConsole.ts +++ b/src/modules/WorkStation/Browser/hooks/useBrowserConsole.ts @@ -12,7 +12,8 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createLogger } from "@src/hooks/logger"; -import { startVisibilityAwarePoller } from "@src/shared/scheduling/visibilityAwarePoller"; +import { LatestScopedTask } from "@src/util/core/latestScopedTask"; +import { startVisibilityAwarePoll } from "@src/util/core/visibilityAwarePoll"; const log = createLogger("useBrowserConsole"); @@ -120,6 +121,8 @@ export function useBrowserConsole( const pollGenerationRef = useRef(0); const entryIdCounter = useRef(0); + const pollCoordinator = useMemo(() => new LatestScopedTask(), []); + // Generate unique ID const generateId = useCallback(() => { entryIdCounter.current += 1; @@ -218,10 +221,11 @@ export function useBrowserConsole( cacheRef.current.delete(closedSessionId); if (closedSessionId === sessionId) { pollGenerationRef.current += 1; + pollCoordinator.supersede(); setEntries([]); } }, - [sessionId] + [pollCoordinator, sessionId] ); // Truncate message if too long @@ -253,97 +257,102 @@ export function useBrowserConsole( if (!enabled || !webviewLabel || !sessionId) return; const generation = pollGenerationRef.current; - try { - const rustEntries = await invoke( - "get_webview_console_logs", - { label: webviewLabel } - ); - - if ( - generation === pollGenerationRef.current && - rustEntries && - rustEntries.length > 0 - ) { - const cache = getSessionCache(sessionId); - - // Rate limit: only process up to maxEntriesPerPoll - const limitedEntries = rustEntries.slice(0, maxEntriesPerPoll); - const droppedCount = rustEntries.length - limitedEntries.length; - - // Transform entries with truncation - let newEntries: ConsoleEntry[] = limitedEntries.map((entry) => ({ - id: generateId(), - level: (entry.level as LogLevel) || "log", - message: truncateMessage(entry.message || ""), - timestamp: entry.timestamp || Date.now(), - url: entry.url || "", - stack: entry.stack ? truncateMessage(entry.stack) : undefined, - })); - - // Deduplicate: collapse repeated consecutive logs - if (deduplicateRepeated && newEntries.length > 0) { - const dedupedEntries: ConsoleEntry[] = []; - let repeatCount = 0; - let lastEntry: ConsoleEntry | null = - cache.entries.length > 0 - ? cache.entries[cache.entries.length - 1] - : null; - - for (const entry of newEntries) { - if (lastEntry && isDuplicate(lastEntry, entry)) { - repeatCount++; - } else { - // Add repeat indicator to previous entry if needed - if (repeatCount > 0 && dedupedEntries.length > 0) { - const prev = dedupedEntries[dedupedEntries.length - 1]; - prev.message = `${prev.message} [×${repeatCount + 1}]`; + const scopeKey = JSON.stringify({ sessionId, webviewLabel }); + await pollCoordinator.run(scopeKey, async (context) => { + try { + const rustEntries = await invoke( + "get_webview_console_logs", + { label: webviewLabel } + ); + if (!context.isCurrent()) return; + + if ( + generation === pollGenerationRef.current && + rustEntries && + rustEntries.length > 0 + ) { + const cache = getSessionCache(sessionId); + + // Rate limit: only process up to maxEntriesPerPoll + const limitedEntries = rustEntries.slice(0, maxEntriesPerPoll); + const droppedCount = rustEntries.length - limitedEntries.length; + + // Transform entries with truncation + let newEntries: ConsoleEntry[] = limitedEntries.map((entry) => ({ + id: generateId(), + level: (entry.level as LogLevel) || "log", + message: truncateMessage(entry.message || ""), + timestamp: entry.timestamp || Date.now(), + url: entry.url || "", + stack: entry.stack ? truncateMessage(entry.stack) : undefined, + })); + + // Deduplicate: collapse repeated consecutive logs + if (deduplicateRepeated && newEntries.length > 0) { + const dedupedEntries: ConsoleEntry[] = []; + let repeatCount = 0; + let lastEntry: ConsoleEntry | null = + cache.entries.length > 0 + ? cache.entries[cache.entries.length - 1] + : null; + + for (const entry of newEntries) { + if (lastEntry && isDuplicate(lastEntry, entry)) { + repeatCount++; + } else { + // Add repeat indicator to previous entry if needed + if (repeatCount > 0 && dedupedEntries.length > 0) { + const prev = dedupedEntries[dedupedEntries.length - 1]; + prev.message = `${prev.message} [×${repeatCount + 1}]`; + } + dedupedEntries.push(entry); + lastEntry = entry; + repeatCount = 0; } - dedupedEntries.push(entry); - lastEntry = entry; - repeatCount = 0; } + + // Handle trailing repeats + if (repeatCount > 0 && dedupedEntries.length > 0) { + const prev = dedupedEntries[dedupedEntries.length - 1]; + prev.message = `${prev.message} [×${repeatCount + 1}]`; + } + + newEntries = dedupedEntries; } - // Handle trailing repeats - if (repeatCount > 0 && dedupedEntries.length > 0) { - const prev = dedupedEntries[dedupedEntries.length - 1]; - prev.message = `${prev.message} [×${repeatCount + 1}]`; + // Add rate limit warning if entries were dropped + if (droppedCount > 0) { + newEntries.push({ + id: generateId(), + level: "warn", + message: `[DevTools] Rate limited: ${droppedCount} log entries dropped`, + timestamp: Date.now(), + url: "", + }); } - newEntries = dedupedEntries; - } + let combined = [...cache.entries, ...newEntries]; + if (combined.length > maxEntries) { + combined = combined.slice(-maxEntries); + } - // Add rate limit warning if entries were dropped - if (droppedCount > 0) { - newEntries.push({ - id: generateId(), - level: "warn", - message: `[DevTools] Rate limited: ${droppedCount} log entries dropped`, - timestamp: Date.now(), - url: "", - }); + updateSessionEntries(sessionId, combined); } - - let combined = [...cache.entries, ...newEntries]; - if (combined.length > maxEntries) { - combined = combined.slice(-maxEntries); + } catch (error) { + // Silently ignore - webview might not exist yet or be closing + if ( + process.env.NODE_ENV === "development" && + !String(error).includes("not found") + ) { + log.debug("[useBrowserConsole] Poll error:", error); } - - updateSessionEntries(sessionId, combined); - } - } catch (error) { - // Silently ignore - webview might not exist yet or be closing - if ( - process.env.NODE_ENV === "development" && - !String(error).includes("not found") - ) { - log.debug("[useBrowserConsole] Poll error:", error); } - } + }); }, [ webviewLabel, sessionId, enabled, + pollCoordinator, generateId, maxEntries, maxEntriesPerPoll, @@ -369,18 +378,27 @@ export function useBrowserConsole( // Start/stop polling useEffect(() => { - if ( - !enabled || - !webviewLabel || - !sessionId || - pollInterval <= 0 || - typeof document === "undefined" - ) { + if (!enabled || !webviewLabel || !sessionId || pollInterval <= 0) { return; } - return startVisibilityAwarePoller(document, pollNow, pollInterval); - }, [enabled, webviewLabel, sessionId, pollInterval, pollNow]); + const poll = startVisibilityAwarePoll({ + intervalMs: pollInterval, + runImmediately: true, + task: pollNow, + }); + return () => { + poll.stop(); + pollCoordinator.supersede(); + }; + }, [ + enabled, + pollCoordinator, + pollInterval, + pollNow, + sessionId, + webviewLabel, + ]); // Compute counts from current entries const { errorCount, warningCount } = useMemo(() => { diff --git a/src/modules/WorkStation/Browser/hooks/useBrowserNetworkLogs.ts b/src/modules/WorkStation/Browser/hooks/useBrowserNetworkLogs.ts index 69a88b496b..0fe65303a1 100644 --- a/src/modules/WorkStation/Browser/hooks/useBrowserNetworkLogs.ts +++ b/src/modules/WorkStation/Browser/hooks/useBrowserNetworkLogs.ts @@ -9,7 +9,8 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createLogger } from "@src/hooks/logger"; -import { startVisibilityAwarePoller } from "@src/shared/scheduling/visibilityAwarePoller"; +import { LatestScopedTask } from "@src/util/core/latestScopedTask"; +import { startVisibilityAwarePoll } from "@src/util/core/visibilityAwarePoll"; const log = createLogger("useBrowserNetworkLogs"); @@ -107,6 +108,8 @@ export function useBrowserNetworkLogs( const [entries, setEntries] = useState([]); const pollGenerationRef = useRef(0); + const pollCoordinator = useMemo(() => new LatestScopedTask(), []); + // Get or create cache entry for a session const getSessionCache = useCallback((sid: string): SessionNetworkCache => { if (!cacheRef.current.has(sid)) { @@ -170,10 +173,11 @@ export function useBrowserNetworkLogs( cacheRef.current.delete(closedSessionId); if (closedSessionId === sessionId) { pollGenerationRef.current += 1; + pollCoordinator.supersede(); setEntries([]); } }, - [sessionId] + [pollCoordinator, sessionId] ); // Poll for network logs from webview @@ -181,52 +185,57 @@ export function useBrowserNetworkLogs( if (!enabled || !webviewLabel || !sessionId) return; const generation = pollGenerationRef.current; - try { - const rustEntries = await invoke( - "get_webview_network_logs", - { label: webviewLabel } - ); - - if ( - generation === pollGenerationRef.current && - rustEntries && - rustEntries.length > 0 - ) { - const cache = getSessionCache(sessionId); - - // Transform entries - const newEntries: NetworkEntry[] = rustEntries.map((entry) => ({ - id: entry.id, - type: (entry.type as "fetch" | "xhr") || "fetch", - method: entry.method || "GET", - url: entry.url || "", - startTime: entry.startTime || Date.now(), - status: entry.status, - duration: entry.duration, - size: entry.size, - error: entry.error, - })); - - let combined = [...cache.entries, ...newEntries]; - if (combined.length > maxEntries) { - combined = combined.slice(-maxEntries); + const scopeKey = JSON.stringify({ sessionId, webviewLabel }); + await pollCoordinator.run(scopeKey, async (context) => { + try { + const rustEntries = await invoke( + "get_webview_network_logs", + { label: webviewLabel } + ); + if (!context.isCurrent()) return; + + if ( + generation === pollGenerationRef.current && + rustEntries && + rustEntries.length > 0 + ) { + const cache = getSessionCache(sessionId); + + // Transform entries + const newEntries: NetworkEntry[] = rustEntries.map((entry) => ({ + id: entry.id, + type: (entry.type as "fetch" | "xhr") || "fetch", + method: entry.method || "GET", + url: entry.url || "", + startTime: entry.startTime || Date.now(), + status: entry.status, + duration: entry.duration, + size: entry.size, + error: entry.error, + })); + + let combined = [...cache.entries, ...newEntries]; + if (combined.length > maxEntries) { + combined = combined.slice(-maxEntries); + } + + updateSessionEntries(sessionId, combined); + } + } catch (error) { + // Silently ignore - webview might not exist yet or be closing + if ( + process.env.NODE_ENV === "development" && + !String(error).includes("not found") + ) { + log.debug("[useBrowserNetworkLogs] Poll error:", error); } - - updateSessionEntries(sessionId, combined); - } - } catch (error) { - // Silently ignore - webview might not exist yet or be closing - if ( - process.env.NODE_ENV === "development" && - !String(error).includes("not found") - ) { - log.debug("[useBrowserNetworkLogs] Poll error:", error); } - } + }); }, [ webviewLabel, sessionId, enabled, + pollCoordinator, maxEntries, getSessionCache, updateSessionEntries, @@ -247,18 +256,27 @@ export function useBrowserNetworkLogs( // Start/stop polling useEffect(() => { - if ( - !enabled || - !webviewLabel || - !sessionId || - pollInterval <= 0 || - typeof document === "undefined" - ) { + if (!enabled || !webviewLabel || !sessionId || pollInterval <= 0) { return; } - return startVisibilityAwarePoller(document, pollNow, pollInterval); - }, [enabled, webviewLabel, sessionId, pollInterval, pollNow]); + const poll = startVisibilityAwarePoll({ + intervalMs: pollInterval, + runImmediately: true, + task: pollNow, + }); + return () => { + poll.stop(); + pollCoordinator.supersede(); + }; + }, [ + enabled, + pollCoordinator, + pollInterval, + pollNow, + sessionId, + webviewLabel, + ]); // Compute error count from current entries const errorCount = useMemo(() => { diff --git a/src/modules/WorkStation/Browser/hooks/useGlobalTokens.ts b/src/modules/WorkStation/Browser/hooks/useGlobalTokens.ts index 526f50f9d2..3b19a818c3 100644 --- a/src/modules/WorkStation/Browser/hooks/useGlobalTokens.ts +++ b/src/modules/WorkStation/Browser/hooks/useGlobalTokens.ts @@ -10,8 +10,12 @@ */ import { invoke } from "@tauri-apps/api/core"; import { useSetAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useMemo } from "react"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { type TokenDefinition, @@ -129,49 +133,55 @@ export function useGlobalTokens( options: UseGlobalTokensOptions = {} ): UseGlobalTokensReturn { const { repoPath, autoScan = true, maxDepth = 5 } = options; - - const [tokens, setTokens] = useState([]); - const [categories, setCategories] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // Update global atom when tokens change const setScannedTokens = useSetAtom(scannedTokensAtom); - /** - * Scan repo for token definitions - */ + const fetchTokens = useCallback( + async ( + serializedScope: string, + context: AsyncResourceFetchContext + ) => { + const scope = JSON.parse(serializedScope) as { + maxDepth: number; + repoPath: string; + }; + try { + const result = await invoke( + "scan_global_tokens", + { + repoPath: scope.repoPath, + maxDepth: scope.maxDepth, + } + ); + if (context.isCurrent()) setScannedTokens(result.tokens); + return result.tokens; + } catch (error) { + log.error( + "[useGlobalTokens] Scan failed:", + error instanceof Error ? error.message : String(error) + ); + throw error; + } + }, + [setScannedTokens] + ); + const scopeKey = repoPath ? JSON.stringify({ maxDepth, repoPath }) : null; + const resource = useAsyncResource({ + autoLoad: autoScan, + enabled: Boolean(scopeKey), + fetcher: fetchTokens, + initialData: [], + scopeKey, + }); + const tokens = resource.data; + const categories = useMemo(() => categorizeTokens(tokens), [tokens]); + const refreshTokens = resource.refresh; const scan = useCallback(async () => { if (!repoPath) { log.warn("[useGlobalTokens] No repo path provided"); return; } - - setLoading(true); - setError(null); - - try { - const result = await invoke( - "scan_global_tokens", - { - repoPath, - maxDepth, - } - ); - - setTokens(result.tokens); - setCategories(categorizeTokens(result.tokens)); - - // Update global token cache - setScannedTokens(result.tokens); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - log.error("[useGlobalTokens] Scan failed:", message); - setError(message); - } finally { - setLoading(false); - } - }, [repoPath, maxDepth, setScannedTokens]); + await refreshTokens(); + }, [refreshTokens, repoPath]); /** * Search tokens by name @@ -221,18 +231,11 @@ export function useGlobalTokens( [tokens] ); - // Auto-scan on mount - useEffect(() => { - if (autoScan && repoPath) { - scan(); - } - }, [autoScan, repoPath, scan]); - return { tokens, categories, - loading, - error, + loading: resource.loading, + error: resource.error, scan, search, getToken, diff --git a/src/modules/WorkStation/Browser/hooks/useWebviewDOMTree.ts b/src/modules/WorkStation/Browser/hooks/useWebviewDOMTree.ts index 0e7e70d797..22ab89f116 100644 --- a/src/modules/WorkStation/Browser/hooks/useWebviewDOMTree.ts +++ b/src/modules/WorkStation/Browser/hooks/useWebviewDOMTree.ts @@ -11,12 +11,16 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useRef, useState } from "react"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { DEBOUNCE_DELAYS, useDebouncedCallback, } from "@src/hooks/perf/useDebouncedCallback"; -import { startVisibilityAwarePoller } from "@src/shared/scheduling/visibilityAwarePoller"; +import { startVisibilityAwarePoll } from "@src/util/core/visibilityAwarePoll"; const log = createLogger("useWebviewDOMTree"); @@ -165,167 +169,99 @@ export function useWebviewDOMTree( onTreeFetched, } = options; - const [tree, setTree] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const [expandedNodes, setExpandedNodes] = useState>( new Set(["/body"]) ); const [highlightedXpath, setHighlightedXpath] = useState(null); - // In-flight guard — prevents dirty-poll tick or navigation from stacking - // concurrent refetches on slow pages (YouTube search with 10k nodes). - const inFlightRef = useRef(false); - // Keep callback ref up to date const onTreeFetchedRef = useRef(onTreeFetched); useEffect(() => { onTreeFetchedRef.current = onTreeFetched; }, [onTreeFetched]); - // Fetch DOM tree - const refresh = useCallback(async () => { - if (!webviewLabel || !enabled) return; - if (inFlightRef.current) return; - - inFlightRef.current = true; - setLoading(true); - setError(null); - - try { - const result = await invoke("get_webview_dom_tree", { - label: webviewLabel, - maxDepth, - }); - - setTree(result); - onTreeFetchedRef.current?.(result); - - // Auto-expand first 2 levels on initial fetch only. - // Functional update preserves prior expandToNode changes made during - // an overlapping async fetch. - if (result) { - setExpandedNodes((currentExpanded) => { - if (currentExpanded.size <= 1) { - return new Set(collectXpathsToDepth(result, 2)); - } - return currentExpanded; - }); - } - } catch (err) { - if (isMissingWebviewError(err)) { - setTree(null); - onTreeFetchedRef.current?.(null); - } else { - const message = err instanceof Error ? err.message : String(err); - setError(message); - } - } finally { - setLoading(false); - inFlightRef.current = false; - } - }, [webviewLabel, enabled, maxDepth]); - - // Initial fetch - useEffect(() => { - if (!enabled || !webviewLabel) return; - - let cancelled = false; - - const doFetch = async () => { - if (inFlightRef.current) return; - inFlightRef.current = true; - setLoading(true); - setError(null); - + const fetchTree = useCallback( + async ( + serializedScope: string, + context: AsyncResourceFetchContext + ) => { + const scope = JSON.parse(serializedScope) as { + maxDepth: number; + webviewLabel: string; + }; try { const result = await invoke( "get_webview_dom_tree", { - label: webviewLabel, - maxDepth, + label: scope.webviewLabel, + maxDepth: scope.maxDepth, } ); - - if (cancelled) return; - - setTree(result); - onTreeFetchedRef.current?.(result); - - if (result) { - setExpandedNodes((currentExpanded) => { - if (currentExpanded.size <= 1) { - return new Set(collectXpathsToDepth(result, 2)); - } - return currentExpanded; - }); + if (context.isCurrent()) { + onTreeFetchedRef.current?.(result); + if (result) { + setExpandedNodes((currentExpanded) => { + if (currentExpanded.size <= 1) { + return new Set(collectXpathsToDepth(result, 2)); + } + return currentExpanded; + }); + } } - } catch (err) { - if (cancelled) return; - if (isMissingWebviewError(err)) { - setTree(null); - onTreeFetchedRef.current?.(null); - } else { - const message = err instanceof Error ? err.message : String(err); - setError(message); + return result; + } catch (error) { + if (isMissingWebviewError(error)) { + if (context.isCurrent()) onTreeFetchedRef.current?.(null); + return null; } - } finally { - if (!cancelled) setLoading(false); - inFlightRef.current = false; + log.error("[useWebviewDOMTree] Fetch failed:", error); + throw error; } - }; - - doFetch(); - - return () => { - cancelled = true; - }; - }, [enabled, webviewLabel, maxDepth]); - - // Smart dirty-check polling. - // - // Rather than unconditionally refetching the whole tree every tick, we - // poll a cheap boolean command that returns `true` only when - // MutationObserver in the webview recorded structural changes since the - // last read. On an idle page, this costs one eval per tick; the - // expensive walk + JSON.stringify only runs when the DOM actually - // changed. - // - // If a refresh is already in-flight (initial fetch, navigation debounce, - // user click), the tick skips — `refresh` itself also guards via - // `inFlightRef`, this is just an extra short-circuit to avoid noise. + }, + [] + ); + const treeScopeKey = + enabled && webviewLabel ? JSON.stringify({ maxDepth, webviewLabel }) : null; + const treeResource = useAsyncResource({ + enabled: Boolean(treeScopeKey), + fetcher: fetchTree, + initialData: null, + scopeKey: treeScopeKey, + }); + const tree = treeResource.data; + const refresh = treeResource.refresh; + const reloadTree = treeResource.reload; + + // Smart dirty-check polling. The visibility-aware recursive timer retains no + // hidden-page timer and never overlaps a tree fetch. useEffect(() => { if (!enabled || !webviewLabel || pollInterval <= 0) return; - let active = true; - - const tick = async () => { - if (!active) return; - if (inFlightRef.current) return; - try { - const dirty = await invoke("check_webview_dom_dirty", { - label: webviewLabel, - }); - if (!active) return; - if (dirty) { - await refresh(); + const poll = startVisibilityAwarePoll({ + intervalMs: pollInterval, + task: async () => { + try { + const dirty = await invoke("check_webview_dom_dirty", { + label: webviewLabel, + }); + if (active && dirty) { + await reloadTree({ background: true }); + } + } catch { + // The webview may have been torn down between scheduling and invoke. } - } catch { - // Swallow — the webview may have been torn down between the - // interval scheduling and the actual call. Next tick will retry. - } - }; - - const stopPolling = startVisibilityAwarePoller( - document, - tick, - pollInterval - ); + }, + }); return () => { active = false; - stopPolling(); + poll.stop(); }; - }, [enabled, webviewLabel, pollInterval, refresh]); + }, [enabled, pollInterval, reloadTree, webviewLabel]); + + /* + * Tree loading is owned by useAsyncResource above. Interaction state below + * remains local because expansion and hover are user intent, not fetch state. + */ // Toggle expanded state const toggleExpanded = useCallback((xpath: string) => { @@ -451,8 +387,8 @@ export function useWebviewDOMTree( return { tree, - loading, - error, + loading: treeResource.loading, + error: treeResource.error, refresh, expandedNodes, toggleExpanded, diff --git a/src/modules/WorkStation/Browser/hooks/useWebviewInspector.ts b/src/modules/WorkStation/Browser/hooks/useWebviewInspector.ts index ef7e0d39ef..78088336e1 100644 --- a/src/modules/WorkStation/Browser/hooks/useWebviewInspector.ts +++ b/src/modules/WorkStation/Browser/hooks/useWebviewInspector.ts @@ -7,10 +7,11 @@ * - Clear selection */ import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createLogger } from "@src/hooks/logger"; -import { startVisibilityAwarePoller } from "@src/shared/scheduling/visibilityAwarePoller"; +import { LatestScopedTask } from "@src/util/core/latestScopedTask"; +import { startVisibilityAwarePoll } from "@src/util/core/visibilityAwarePoll"; const log = createLogger("useWebviewInspector"); @@ -135,6 +136,7 @@ export function useWebviewInspector( null ); const [isLoading, setIsLoading] = useState(false); + const selectionCoordinator = useMemo(() => new LatestScopedTask(), []); // Track previous selection to detect changes const prevSelectionRef = useRef(null); @@ -200,28 +202,31 @@ export function useWebviewInspector( const refreshSelection = useCallback(async () => { if (!webviewLabel) return; - try { - const element = await invoke( - "get_selected_element_info", - { label: webviewLabel } - ); - - if (element) { - // Check if selection changed - const selectionKey = element.xpath || element.selector; - if (selectionKey !== prevSelectionRef.current) { - prevSelectionRef.current = selectionKey; - setSelectedElement(element); - onElementSelectedRef.current?.(element); + await selectionCoordinator.run(webviewLabel, async (context) => { + try { + const element = await invoke( + "get_selected_element_info", + { label: webviewLabel } + ); + if (!context.isCurrent()) return; + + if (element) { + // Check if selection changed + const selectionKey = element.xpath || element.selector; + if (selectionKey !== prevSelectionRef.current) { + prevSelectionRef.current = selectionKey; + setSelectedElement(element); + onElementSelectedRef.current?.(element); + } } + } catch (error) { + log.warn( + "[useWebviewInspector] Polling error:", + error instanceof Error ? error.message : String(error) + ); } - } catch (error) { - log.warn( - "[useWebviewInspector] Polling error:", - error instanceof Error ? error.message : String(error) - ); - } - }, [webviewLabel]); + }); + }, [selectionCoordinator, webviewLabel]); // Clear selection const clearSelection = useCallback(async () => { @@ -242,8 +247,23 @@ export function useWebviewInspector( return; } - return startVisibilityAwarePoller(document, refreshSelection, pollInterval); - }, [isInspectMode, webviewLabel, enabled, pollInterval, refreshSelection]); + const poll = startVisibilityAwarePoll({ + intervalMs: pollInterval, + runImmediately: true, + task: refreshSelection, + }); + return () => { + poll.stop(); + selectionCoordinator.supersede(); + }; + }, [ + enabled, + isInspectMode, + pollInterval, + refreshSelection, + selectionCoordinator, + webviewLabel, + ]); // Cleanup on unmount or webview change useEffect(() => { diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useGitWorktrees.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useGitWorktrees.ts index 05a8f461a9..b5ac161c68 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useGitWorktrees.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useGitWorktrees.ts @@ -5,7 +5,7 @@ * Returns linked (non-main) worktrees only — the main worktree * is already displayed by the primary Source Control section. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect } from "react"; import type { GitWorktreeDiffSummary, @@ -13,7 +13,7 @@ import type { } from "@src/api/http/git/types"; import { getGitWorktrees } from "@src/api/http/git/worktrees"; import { getCodeEditorWebSocket } from "@src/api/realtime/codeEditorWebSocket"; -import { useMountedCleanup } from "@src/hooks/lifecycle/useMounted"; +import { useAsyncResource } from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { DEBOUNCE_DELAYS, @@ -39,58 +39,60 @@ export interface UseGitWorktreesResult { refresh: () => Promise; } +interface GitWorktreesData { + worktrees: GitWorktreeEntry[]; + mainDiffSummary: GitWorktreeDiffSummary | null; +} + +const EMPTY_WORKTREES: GitWorktreesData = { + worktrees: [], + mainDiffSummary: null, +}; + export function useGitWorktrees({ repoId, repoPath, enabled = true, }: UseGitWorktreesOptions): UseGitWorktreesResult { - const [worktrees, setWorktrees] = useState([]); - const [mainDiffSummary, setMainDiffSummary] = - useState(null); - const [loading, setLoading] = useState(enabled); - const loadedRef = useRef(false); - const mountedRef = useRef(true); - useMountedCleanup(mountedRef); - - const fetchWorktrees = useCallback(async () => { - if (!enabled) return; - - if (!loadedRef.current) setLoading(true); + const fetchWorktrees = useCallback(async (serializedScope: string) => { + const scope = JSON.parse(serializedScope) as { + repoId: string; + repoPath: string; + }; try { const entries = await getGitWorktrees({ - repo_id: repoId, - repo_path: repoPath, + repo_id: scope.repoId, + repo_path: scope.repoPath, }); - if (!mountedRef.current) return; - setWorktrees(entries.filter((entry) => !entry.is_main)); - setMainDiffSummary(extractMainWorktreeDiffSummary(entries)); + return { + worktrees: entries.filter((entry) => !entry.is_main), + mainDiffSummary: extractMainWorktreeDiffSummary(entries), + }; } catch (error) { logger.warn("Failed to fetch git worktrees", error); - } finally { - if (mountedRef.current) { - loadedRef.current = true; - setLoading(false); - } + throw error; } - }, [enabled, repoId, repoPath, mountedRef]); + }, []); + const scopeKey = + enabled && repoId ? JSON.stringify({ repoId, repoPath }) : null; + const resource = useAsyncResource({ + enabled: Boolean(scopeKey), + fetcher: fetchWorktrees, + initialData: EMPTY_WORKTREES, + scopeKey, + }); + const reloadWorktrees = resource.reload; + const resourceStatus = resource.status; + const refresh = useCallback( + () => reloadWorktrees({ background: resourceStatus === "ready" }), + [reloadWorktrees, resourceStatus] + ); const debouncedFetch = useDebouncedCallback( - () => fetchWorktrees(), + () => reloadWorktrees({ background: true }), DEBOUNCE_DELAYS.API ); - useEffect(() => { - loadedRef.current = false; - setLoading(enabled); - if (!enabled) { - setWorktrees([]); - setMainDiffSummary(null); - return; - } - - void fetchWorktrees(); - }, [enabled, fetchWorktrees]); - useEffect(() => { if (!enabled) return; @@ -114,13 +116,13 @@ export function useGitWorktrees({ }; }, [enabled, repoId, debouncedFetch]); - const visibleWorktrees = enabled ? worktrees : []; + const visibleWorktrees = resource.data.worktrees; return { worktrees: visibleWorktrees, - mainDiffSummary: enabled ? mainDiffSummary : null, + mainDiffSummary: resource.data.mainDiffSummary, hasWorktrees: visibleWorktrees.length > 0, - loading, - refresh: fetchWorktrees, + loading: resource.loading, + refresh, }; } diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useStashState.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useStashState.ts index da6fc7a1e6..cfa31114e9 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useStashState.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useStashState.ts @@ -15,6 +15,10 @@ import { useTranslation } from "react-i18next"; import { useActionSystemOptional } from "@src/ActionSystem"; import { gitApi } from "@src/api/http/git"; import type { StashEntry } from "@src/api/http/git/types"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { showGitActionDialogSafely } from "@src/util/dialogs/gitActionDialog"; @@ -69,38 +73,55 @@ export function useStashState( const actionSystem = useActionSystemOptional(); const dispatch = actionSystem?.dispatch; - const [stashes, setStashes] = useState([]); - const [loading, setLoading] = useState(false); const [operationLoading, setOperationLoading] = useState(false); const [error, setError] = useState(null); - // Fetch stash list (data fetching, not an action - remains gitApi) + const fetchStashes = useCallback( + async ( + serializedScope: string, + context: AsyncResourceFetchContext + ) => { + const scope = JSON.parse(serializedScope) as { + repoId: string; + repoPath: string; + }; + try { + const result = await gitApi.gitStashList({ + repo_id: scope.repoId, + repo_path: scope.repoPath, + }); + return result?.stashes ?? []; + } catch (caughtError) { + log.error("[useStashState] Failed to fetch stash list:", caughtError); + if (context.isCurrent()) context.publish([]); + throw caughtError; + } + }, + [] + ); + const scopeKey = repoId ? JSON.stringify({ repoId, repoPath }) : null; + const stashResource = useAsyncResource({ + autoLoad, + enabled: Boolean(scopeKey), + fetcher: fetchStashes, + initialData: [], + scopeKey, + }); + const refreshResource = stashResource.refresh; const refresh = useCallback(async () => { - if (!repoId) return; - - setLoading(true); setError(null); + await refreshResource(); + }, [refreshResource]); - try { - const result = await gitApi.gitStashList({ - repo_id: repoId, - repo_path: repoPath, - }); - - if (result) { - setStashes(result.stashes); - } else { - setStashes([]); - } - } catch (err) { - log.error("[useStashState] Failed to fetch stash list:", err); - setError(err instanceof Error ? err.message : "Failed to fetch stashes"); - setStashes([]); - } finally { - setLoading(false); - } + useEffect(() => { + setError(null); }, [repoId, repoPath]); + /* + * Operation state remains local: it represents an explicit user mutation, + * while the list resource above owns only read/refresh lifecycle. + */ + // Create a new stash - uses dispatch const stashPush = useCallback( async (message?: string, includeUntracked = false): Promise => { @@ -390,18 +411,11 @@ export function useStashState( [repoId, repoPath, refresh, dispatch, t] ); - // Auto-load on mount - useEffect(() => { - if (autoLoad && repoId) { - refresh(); - } - }, [autoLoad, repoId, refresh]); - return { - stashes, - loading, - error, - stashCount: stashes.length, + stashes: stashResource.data, + loading: stashResource.loading, + error: error ?? stashResource.error, + stashCount: stashResource.data.length, refresh, stashPush, stashApply, diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationIssues.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationIssues.ts index 38c92e88d8..2f53ed18df 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationIssues.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationIssues.ts @@ -10,6 +10,10 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getGitRemotes } from "@src/api/http/git/remotes"; +import { + type AsyncResourceFetchContext, + useAsyncResource, +} from "@src/hooks/async"; import { createLogger } from "@src/hooks/logger"; import { getCachedIssues, @@ -42,6 +46,7 @@ import { } from "@src/store/workstation/codeEditor/workstationIssueAtom"; import type { IssueFilterState } from "@src/store/workstation/codeEditor/workstationIssueAtom"; import { workstationRepoScopeKey } from "@src/store/workstation/codeEditor/workstationPrAtom"; +import { LatestScopedTask } from "@src/util/core/latestScopedTask"; import { filterIssuesByQuery } from "./workstationIssueHelpers"; @@ -75,6 +80,39 @@ function mergeUniqueIssues( ]; } +interface IssueSectionData { + hasMore: boolean; + issues: GitHubIssue[]; + nextPage: number | null; +} + +interface IssueSectionScope { + remoteUrl: string; + repoKey: string; + state: "closed" | "open"; +} + +interface PaginationState { + error: string | null; + scopeKey: string | null; + status: "error" | "idle" | "loading"; +} + +interface IssueRepoMetadata { + collaborators: GitHubIssueUser[]; + labels: GitHubIssueLabel[]; +} + +const EMPTY_PAGINATION_STATE: PaginationState = { + error: null, + scopeKey: null, + status: "idle", +}; +const EMPTY_ISSUE_REPO_METADATA: IssueRepoMetadata = { + collaborators: [], + labels: [], +}; + export function useWorkstationIssues({ repoPath, repoId, @@ -103,66 +141,59 @@ export function useWorkstationIssues({ // ── Auth / remote URL resolution ────────────────────────────────────────── - const [resolvedRemoteUrl, setResolvedRemoteUrl] = useState( - null - ); // Optimistic auth flag: true when the remote is a GitHub URL. // Credentials are resolved Rust-side from connection_token_store — no // pre-flight token ping needed. Real auth failures from API calls will // flip this to false, matching the trust model used by the PR panel. // Track whether we're still waiting for the remote URL to resolve so the // panel shows a spinner instead of the empty-state placeholder. - const [remoteUrlLoading, setRemoteUrlLoading] = useState(true); // Set to true when the API returns a re-authorization error so the UI can // show a targeted prompt instead of a generic error or empty state. - const [needsReAuth, setNeedsReAuth] = useState(false); - - const [repoLabels, setRepoLabels] = useState([]); - const [collaborators, setCollaborators] = useState([]); - - // Resolve origin remote URL if not provided via props - useEffect(() => { - let cancelled = false; - - void (async () => { - if (remoteUrlProp) { - logger.debug("remote URL from prop", remoteUrlProp); - if (!cancelled) { - setResolvedRemoteUrl(remoteUrlProp); - setRemoteUrlLoading(false); - } - return; - } - if (!repoPath) { - if (!cancelled) setRemoteUrlLoading(false); - return; - } - - logger.debug("fetching remotes", { repoPath, repoId: apiRepoId }); - try { - const remotesData = await getGitRemotes({ - repo_id: apiRepoId, - repo_path: repoPath, - }); - logger.debug("getGitRemotes result", remotesData); - const origin = remotesData?.remotes?.find((r) => r.name === "origin"); - logger.debug("origin remote", origin); - if (!cancelled) { - if (origin?.url) { - setResolvedRemoteUrl(origin.url); - } - setRemoteUrlLoading(false); - } - } catch (err) { - logger.warn("getGitRemotes failed", err); - if (!cancelled) setRemoteUrlLoading(false); - } - })(); - - return () => { - cancelled = true; + const [reAuthScopeKey, setReAuthScopeKey] = useState(null); + + const remoteScopeKey = JSON.stringify({ + apiRepoId, + remoteUrl: remoteUrlProp ?? null, + repoPath, + }); + const resolveRemoteUrl = useCallback(async (serializedScope: string) => { + const scope = JSON.parse(serializedScope) as { + apiRepoId: string; + remoteUrl: string | null; + repoPath: string; }; - }, [repoPath, apiRepoId, remoteUrlProp]); + if (scope.remoteUrl) { + logger.debug("remote URL from prop", scope.remoteUrl); + return scope.remoteUrl; + } + if (!scope.repoPath) return null; + + logger.debug("fetching remotes", { + repoPath: scope.repoPath, + repoId: scope.apiRepoId, + }); + try { + const remotesData = await getGitRemotes({ + repo_id: scope.apiRepoId, + repo_path: scope.repoPath, + }); + const origin = remotesData?.remotes?.find( + (remote) => remote.name === "origin" + ); + logger.debug("origin remote", origin); + return origin?.url ?? null; + } catch (error) { + logger.warn("getGitRemotes failed", error); + return null; + } + }, []); + const remoteResource = useAsyncResource({ + fetcher: resolveRemoteUrl, + initialData: null, + scopeKey: remoteScopeKey, + }); + const resolvedRemoteUrl = remoteResource.data; + const remoteUrlLoading = remoteResource.loading; // Optimistically true when the remote resolves to a GitHub URL. // A valid GitHub URL means credentials should be available via @@ -202,179 +233,261 @@ export function useWorkstationIssues({ type SectionLoadState = "idle" | "loading" | "ready" | "error"; // Seed from cache immediately so the list shows on re-entry without a spinner - const cached = getCachedIssues(repoKey); - const [openLoadState, setOpenLoadState] = useState( - cached ? "ready" : "idle" - ); - const [closedLoadState, setClosedLoadState] = useState( - cached?.closedIssues.length && !isIssueCacheStale(repoKey, "closed") - ? "ready" - : "idle" - ); - const [openIssues, setOpenIssues] = useState( - cached?.openIssues ?? [] + const cached = useMemo(() => getCachedIssues(repoKey), [repoKey]); + const openCacheStale = useMemo(() => isIssueCacheStale(repoKey), [repoKey]); + const closedCacheReady = useMemo( + () => + Boolean(cached?.closedIssues.length) && + !isIssueCacheStale(repoKey, "closed"), + [cached, repoKey] ); - const [closedIssues, setClosedIssues] = useState( - cached?.closedIssues ?? [] + const openInitialData = useMemo( + () => ({ + hasMore: (cached?.openIssues.length ?? 0) >= ISSUE_PAGE_SIZE, + issues: cached?.openIssues ?? [], + nextPage: (cached?.openIssues.length ?? 0) >= ISSUE_PAGE_SIZE ? 2 : null, + }), + [cached] ); - const [openHasMore, setOpenHasMore] = useState( - (cached?.openIssues.length ?? 0) >= ISSUE_PAGE_SIZE + const closedInitialData = useMemo( + () => ({ + hasMore: (cached?.closedIssues.length ?? 0) >= ISSUE_PAGE_SIZE, + issues: cached?.closedIssues ?? [], + nextPage: + (cached?.closedIssues.length ?? 0) >= ISSUE_PAGE_SIZE ? 2 : null, + }), + [cached] ); - const [closedHasMore, setClosedHasMore] = useState( - (cached?.closedIssues.length ?? 0) >= ISSUE_PAGE_SIZE - ); - const [openNextPage, setOpenNextPage] = useState( - (cached?.openIssues.length ?? 0) >= ISSUE_PAGE_SIZE ? 2 : null + + const openScopeKey = useMemo( + () => + resolvedRemoteUrl && hasGitHubAuth + ? JSON.stringify({ + remoteUrl: resolvedRemoteUrl, + repoKey, + state: "open", + } satisfies IssueSectionScope) + : null, + [hasGitHubAuth, repoKey, resolvedRemoteUrl] ); - const [closedNextPage, setClosedNextPage] = useState( - (cached?.closedIssues.length ?? 0) >= ISSUE_PAGE_SIZE ? 2 : null + const closedScopeKey = useMemo( + () => + resolvedRemoteUrl && hasGitHubAuth + ? JSON.stringify({ + remoteUrl: resolvedRemoteUrl, + repoKey, + state: "closed", + } satisfies IssueSectionScope) + : null, + [hasGitHubAuth, repoKey, resolvedRemoteUrl] ); - const [openLoadingMore, setOpenLoadingMore] = useState(false); - const [closedLoadingMore, setClosedLoadingMore] = useState(false); - const [openError, setOpenError] = useState(null); - const [closedError, setClosedError] = useState(null); - - const handleFetchError = useCallback( - ( - error: string, - setError: (e: string | null) => void, - setLoad: (s: SectionLoadState) => void + const needsReAuth = + reAuthScopeKey === openScopeKey || reAuthScopeKey === closedScopeKey; + + const fetchIssueSection = useCallback( + async ( + serializedScope: string, + context: AsyncResourceFetchContext ) => { - const isReAuth = - /ReAuthError/i.test(error) || /re-authorization required/i.test(error); - if (isReAuth) { - setNeedsReAuth(true); - } else { - setError(error); + const scope = JSON.parse(serializedScope) as IssueSectionScope; + const result = await fetchIssues(scope.remoteUrl, { + state: scope.state, + page: 1, + perPage: ISSUE_PAGE_SIZE, + }); + if (result.error) { + const isReAuth = + /ReAuthError/i.test(result.error) || + /re-authorization required/i.test(result.error); + if (isReAuth && context.isCurrent()) { + setReAuthScopeKey(serializedScope); + } + throw new Error(result.error); } - setLoad("error"); + const data = { + hasMore: result.data!.has_more, + issues: result.data!.issues, + nextPage: result.data!.next_page, + }; + if (context.isCurrent()) { + if (scope.state === "open") { + updateCachedOpenIssues(scope.repoKey, data.issues); + } else { + updateCachedClosedIssues(scope.repoKey, data.issues); + } + } + return data; }, - [setNeedsReAuth] + [] ); - const fetchOpen = useCallback(async () => { - if (!resolvedRemoteUrl || !hasGitHubAuth) return; - setOpenLoadState("loading"); - setOpenError(null); - const result = await fetchIssues(resolvedRemoteUrl, { - state: "open", - page: 1, - perPage: ISSUE_PAGE_SIZE, - }); - if (!mountedRef.current) return; - if (result.error) { - handleFetchError(result.error, setOpenError, setOpenLoadState); - return; - } - const issues = result.data!.issues; - setOpenIssues(issues); - setOpenHasMore(result.data!.has_more); - setOpenNextPage(result.data!.next_page); - setOpenLoadState("ready"); - updateCachedOpenIssues(repoKey, issues); - }, [resolvedRemoteUrl, hasGitHubAuth, handleFetchError, repoKey]); - - const fetchClosed = useCallback(async () => { - if (!resolvedRemoteUrl || !hasGitHubAuth) return; - setClosedLoadState("loading"); - setClosedError(null); - const result = await fetchIssues(resolvedRemoteUrl, { - state: "closed", - page: 1, - perPage: ISSUE_PAGE_SIZE, - }); - if (!mountedRef.current) return; - if (result.error) { - handleFetchError(result.error, setClosedError, setClosedLoadState); - return; - } - const issues = result.data!.issues; - setClosedIssues(issues); - setClosedHasMore(result.data!.has_more); - setClosedNextPage(result.data!.next_page); - setClosedLoadState("ready"); - updateCachedClosedIssues(repoKey, issues); - }, [resolvedRemoteUrl, hasGitHubAuth, handleFetchError, repoKey]); + const openResource = useAsyncResource({ + autoLoad: Boolean(openScopeKey) && openCacheStale, + enabled: Boolean(openScopeKey), + fetcher: fetchIssueSection, + initialData: openInitialData, + initialStatus: cached ? "ready" : "idle", + scopeKey: openScopeKey, + }); + const closedResource = useAsyncResource({ + autoLoad: false, + enabled: Boolean(closedScopeKey), + fetcher: fetchIssueSection, + initialData: closedInitialData, + initialStatus: closedCacheReady ? "ready" : "idle", + scopeKey: closedScopeKey, + }); + + const { + data: openData, + error: openResourceError, + refresh: fetchOpen, + setData: setOpenData, + status: openResourceStatus, + } = openResource; + const { + data: closedData, + error: closedResourceError, + refresh: fetchClosed, + setData: setClosedData, + status: closedResourceStatus, + } = closedResource; + const [openPagination, setOpenPagination] = useState( + EMPTY_PAGINATION_STATE + ); + const [closedPagination, setClosedPagination] = useState( + EMPTY_PAGINATION_STATE + ); + const openPageCoordinator = useMemo(() => new LatestScopedTask(), []); + const closedPageCoordinator = useMemo(() => new LatestScopedTask(), []); + + useEffect(() => { + openPageCoordinator.supersede(); + return () => openPageCoordinator.supersede(); + }, [openPageCoordinator, openScopeKey]); + useEffect(() => { + closedPageCoordinator.supersede(); + return () => closedPageCoordinator.supersede(); + }, [closedPageCoordinator, closedScopeKey]); const loadMoreOpen = useCallback(async () => { - if (!resolvedRemoteUrl || !hasGitHubAuth || !openHasMore || !openNextPage) - return; - setOpenLoadingMore(true); - setOpenError(null); - const result = await fetchIssues(resolvedRemoteUrl, { - state: "open", - page: openNextPage, - perPage: ISSUE_PAGE_SIZE, - }); - if (!mountedRef.current) return; - setOpenLoadingMore(false); - if (result.error) { - handleFetchError(result.error, setOpenError, setOpenLoadState); - return; - } - setOpenIssues((current) => { - const issues = mergeUniqueIssues(current, result.data!.issues); - updateCachedOpenIssues(repoKey, issues); - return issues; - }); - setOpenHasMore(result.data!.has_more); - setOpenNextPage(result.data!.next_page); - }, [ - resolvedRemoteUrl, - hasGitHubAuth, - openHasMore, - openNextPage, - handleFetchError, - repoKey, - ]); + if (!openScopeKey || !openData.hasMore || !openData.nextPage) return; + const scope = JSON.parse(openScopeKey) as IssueSectionScope; + await openPageCoordinator.run( + `${openScopeKey}:${openData.nextPage}`, + async (context) => { + setOpenPagination({ + error: null, + scopeKey: openScopeKey, + status: "loading", + }); + const result = await fetchIssues(scope.remoteUrl, { + state: "open", + page: openData.nextPage!, + perPage: ISSUE_PAGE_SIZE, + }); + if (!context.isCurrent()) return; + if (result.error) { + const isReAuth = + /ReAuthError/i.test(result.error) || + /re-authorization required/i.test(result.error); + if (isReAuth) setReAuthScopeKey(openScopeKey); + setOpenPagination({ + error: isReAuth ? null : result.error, + scopeKey: openScopeKey, + status: "error", + }); + return; + } + setOpenData((current) => { + const issues = mergeUniqueIssues(current.issues, result.data!.issues); + updateCachedOpenIssues(scope.repoKey, issues); + return { + hasMore: result.data!.has_more, + issues, + nextPage: result.data!.next_page, + }; + }); + setOpenPagination({ + error: null, + scopeKey: openScopeKey, + status: "idle", + }); + } + ); + }, [openData, openPageCoordinator, openScopeKey, setOpenData]); const loadMoreClosed = useCallback(async () => { - if ( - !resolvedRemoteUrl || - !hasGitHubAuth || - !closedHasMore || - !closedNextPage - ) - return; - setClosedLoadingMore(true); - setClosedError(null); - const result = await fetchIssues(resolvedRemoteUrl, { - state: "closed", - page: closedNextPage, - perPage: ISSUE_PAGE_SIZE, - }); - if (!mountedRef.current) return; - setClosedLoadingMore(false); - if (result.error) { - handleFetchError(result.error, setClosedError, setClosedLoadState); - return; - } - setClosedIssues((current) => { - const issues = mergeUniqueIssues(current, result.data!.issues); - updateCachedClosedIssues(repoKey, issues); - return issues; - }); - setClosedHasMore(result.data!.has_more); - setClosedNextPage(result.data!.next_page); - }, [ - resolvedRemoteUrl, - hasGitHubAuth, - closedHasMore, - closedNextPage, - handleFetchError, - repoKey, - ]); - - // Fetch open issues on mount / auth ready. - // Skip the network hit when the cache is still fresh (< 10 min) — the UI - // already shows cached rows so there's no spinner flash on re-entry. - // Deferred via setTimeout to avoid synchronous setState inside effect body. - useEffect(() => { - if (!resolvedRemoteUrl || !hasGitHubAuth) return; - if (!isIssueCacheStale(repoKey)) return; - const timer = setTimeout(() => void fetchOpen(), 0); - return () => clearTimeout(timer); - }, [resolvedRemoteUrl, hasGitHubAuth, fetchOpen, repoKey]); + if (!closedScopeKey || !closedData.hasMore || !closedData.nextPage) return; + const scope = JSON.parse(closedScopeKey) as IssueSectionScope; + await closedPageCoordinator.run( + `${closedScopeKey}:${closedData.nextPage}`, + async (context) => { + setClosedPagination({ + error: null, + scopeKey: closedScopeKey, + status: "loading", + }); + const result = await fetchIssues(scope.remoteUrl, { + state: "closed", + page: closedData.nextPage!, + perPage: ISSUE_PAGE_SIZE, + }); + if (!context.isCurrent()) return; + if (result.error) { + const isReAuth = + /ReAuthError/i.test(result.error) || + /re-authorization required/i.test(result.error); + if (isReAuth) setReAuthScopeKey(closedScopeKey); + setClosedPagination({ + error: isReAuth ? null : result.error, + scopeKey: closedScopeKey, + status: "error", + }); + return; + } + setClosedData((current) => { + const issues = mergeUniqueIssues(current.issues, result.data!.issues); + updateCachedClosedIssues(scope.repoKey, issues); + return { + hasMore: result.data!.has_more, + issues, + nextPage: result.data!.next_page, + }; + }); + setClosedPagination({ + error: null, + scopeKey: closedScopeKey, + status: "idle", + }); + } + ); + }, [closedData, closedPageCoordinator, closedScopeKey, setClosedData]); + + const openLoadState: SectionLoadState = + openResourceStatus === "refreshing" ? "loading" : openResourceStatus; + const closedLoadState: SectionLoadState = + closedResourceStatus === "refreshing" ? "loading" : closedResourceStatus; + const openIssues = openData.issues; + const closedIssues = closedData.issues; + const openHasMore = openData.hasMore; + const closedHasMore = closedData.hasMore; + const openLoadingMore = + openPagination.scopeKey === openScopeKey && + openPagination.status === "loading"; + const closedLoadingMore = + closedPagination.scopeKey === closedScopeKey && + closedPagination.status === "loading"; + const openError = needsReAuth + ? null + : (openResourceError ?? + (openPagination.scopeKey === openScopeKey ? openPagination.error : null)); + const closedError = needsReAuth + ? null + : (closedResourceError ?? + (closedPagination.scopeKey === closedScopeKey + ? closedPagination.error + : null)); const refresh = useCallback(() => { void fetchOpen(); @@ -401,25 +514,24 @@ export function useWorkstationIssues({ // Refetch on debounced search change (client-side filter applied in UI) // Search filtering is done client-side via filterIssuesByQuery helper - // Fetch repo labels + collaborators once auth is available - useEffect(() => { - if (!resolvedRemoteUrl || !hasGitHubAuth) return; - let cancelled = false; - - void (async () => { - const [labelsResult, collabResult] = await Promise.all([ - fetchRepoLabels(resolvedRemoteUrl), - fetchRepoCollaborators(resolvedRemoteUrl), - ]); - if (cancelled) return; - if (labelsResult.data) setRepoLabels(labelsResult.data); - if (collabResult.data) setCollaborators(collabResult.data); - })(); - - return () => { - cancelled = true; + const fetchRepoMetadata = useCallback(async (remoteUrl: string) => { + const [labelsResult, collaboratorsResult] = await Promise.all([ + fetchRepoLabels(remoteUrl), + fetchRepoCollaborators(remoteUrl), + ]); + return { + collaborators: collaboratorsResult.data ?? [], + labels: labelsResult.data ?? [], }; - }, [resolvedRemoteUrl, hasGitHubAuth]); + }, []); + const repoMetadataResource = useAsyncResource({ + enabled: Boolean(resolvedRemoteUrl && hasGitHubAuth), + fetcher: fetchRepoMetadata, + initialData: EMPTY_ISSUE_REPO_METADATA, + scopeKey: resolvedRemoteUrl && hasGitHubAuth ? resolvedRemoteUrl : null, + }); + const repoLabels = repoMetadataResource.data.labels; + const collaborators = repoMetadataResource.data.collaborators; // ── Issue selection ─────────────────────────────────────────────────────── diff --git a/src/modules/shared/Error/index.tsx b/src/modules/shared/Error/index.tsx index c33436a93c..7b2b04f95b 100644 --- a/src/modules/shared/Error/index.tsx +++ b/src/modules/shared/Error/index.tsx @@ -93,9 +93,24 @@ function getErrorInfo(error: unknown): { title: string; message: string } { }; } +function getErrorDiagnostic( + error: unknown, + message: string, + componentStack?: string +): string { + const errorDetail = error instanceof Error ? error.stack || message : message; + const componentDetail = componentStack?.trim(); + + return componentDetail + ? `${errorDetail}\n\nReact component stack:\n${componentDetail}` + : errorDetail; +} + interface ErrorPageProps { /** Error passed from ErrorBoundary (optional) */ error?: Error; + /** React component stack captured by ErrorBoundary (optional) */ + componentStack?: string; } /** @@ -104,6 +119,7 @@ interface ErrorPageProps { */ const ErrorPageWithRouter: React.FC = ({ error: propError, + componentStack, }) => { // Get error from React Router (works when used as errorElement) const routeError = useRouteError(); @@ -111,13 +127,18 @@ const ErrorPageWithRouter: React.FC = ({ // Use route error if available, otherwise use prop error const errorToShow = routeError || propError; - return ; + return ( + + ); }; /** * Core error page content - receives error from either source */ -const ErrorPageContent: React.FC<{ error?: unknown }> = ({ error }) => { +const ErrorPageContent: React.FC<{ + error?: unknown; + componentStack?: string; +}> = ({ error, componentStack }) => { // Extract user-friendly error info const { title, message: rawMessage } = useMemo( () => getErrorInfo(error), @@ -127,6 +148,10 @@ const ErrorPageContent: React.FC<{ error?: unknown }> = ({ error }) => { // Clean message for display (strip ANSI codes) const cleanMessage = useMemo(() => stripAnsiCodes(rawMessage), [rawMessage]); + const copyDiagnostic = useMemo( + () => stripAnsiCodes(getErrorDiagnostic(error, rawMessage, componentStack)), + [componentStack, error, rawMessage] + ); // Truncated message for display only const displayMessage = useMemo( @@ -136,14 +161,13 @@ const ErrorPageContent: React.FC<{ error?: unknown }> = ({ error }) => { const handleCopy = useCallback(async () => { try { - // Copy the full clean message (not truncated) - await copyText(cleanMessage); + await copyText(copyDiagnostic); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (err) { logger.error("Failed to copy:", err); } - }, [cleanMessage]); + }, [copyDiagnostic]); // Log error for debugging useEffect(() => { @@ -263,7 +287,12 @@ const ErrorPage: React.FC = (props) => { // If we have an error prop (from ErrorBoundary), render content directly // This avoids calling useRouteError outside of a router context if (props.error) { - return ; + return ( + + ); } // Otherwise, try to get error from router (for errorElement usage) diff --git a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.test.ts b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.test.ts new file mode 100644 index 0000000000..0698c7265c --- /dev/null +++ b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CountdownScheduler } from "./countdownScheduler"; + +describe("CountdownScheduler", () => { + let documentTarget: EventTarget & { visibilityState: string }; + let now: number; + + beforeEach(() => { + vi.useFakeTimers(); + now = 1_000; + documentTarget = Object.assign(new EventTarget(), { + visibilityState: "visible", + }); + vi.stubGlobal( + "window", + Object.assign(new EventTarget(), { + clearTimeout: globalThis.clearTimeout, + setTimeout: globalThis.setTimeout, + }) + ); + vi.stubGlobal("document", documentTarget); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("updates at most once per second and stops at expiry", () => { + const onUpdate = vi.fn(); + const scheduler = new CountdownScheduler(3_500, onUpdate, () => now); + scheduler.start(); + + expect(onUpdate).toHaveBeenLastCalledWith(2_500); + now = 2_000; + vi.advanceTimersByTime(1_000); + expect(onUpdate).toHaveBeenLastCalledWith(1_500); + now = 3_500; + vi.advanceTimersByTime(1_000); + expect(onUpdate).toHaveBeenLastCalledWith(0); + + vi.advanceTimersByTime(10_000); + expect(onUpdate).toHaveBeenCalledTimes(3); + scheduler.stop(); + }); + + it("pauses while hidden and recalculates once when visible", () => { + const onUpdate = vi.fn(); + const scheduler = new CountdownScheduler(10_000, onUpdate, () => now); + scheduler.start(); + + documentTarget.visibilityState = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + now = 7_000; + vi.advanceTimersByTime(10_000); + expect(onUpdate).toHaveBeenCalledTimes(1); + + documentTarget.visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + expect(onUpdate).toHaveBeenLastCalledWith(3_000); + expect(onUpdate).toHaveBeenCalledTimes(2); + scheduler.stop(); + }); + + it("removes timers and listeners on stop", () => { + const onUpdate = vi.fn(); + const scheduler = new CountdownScheduler(10_000, onUpdate, () => now); + scheduler.start(); + scheduler.stop(); + + now = 5_000; + vi.advanceTimersByTime(10_000); + document.dispatchEvent(new Event("visibilitychange")); + expect(onUpdate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.ts b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.ts new file mode 100644 index 0000000000..0ad8b94d4f --- /dev/null +++ b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.ts @@ -0,0 +1,68 @@ +export function getCountdownRemaining( + expiresAt: number, + now: () => number = Date.now +): number { + return Math.max(0, expiresAt - now()); +} + +/** + * Owns the proposal countdown's single timer and visibility listener. + * + * The UI label only changes once per second, so frame-rate updates would + * needlessly re-render the full proposal creator. Hidden windows keep no timer; + * returning to the foreground recalculates from the absolute expiry time. + */ +export class CountdownScheduler { + private timeoutId: number | undefined; + private running = false; + + constructor( + private readonly expiresAt: number, + private readonly onUpdate: (remaining: number) => void, + private readonly now: () => number = Date.now + ) {} + + start(): void { + this.stop(); + this.running = true; + document.addEventListener("visibilitychange", this.handleVisibilityChange); + this.updateAndSchedule(); + } + + stop(): void { + this.running = false; + this.clearScheduledUpdate(); + document.removeEventListener( + "visibilitychange", + this.handleVisibilityChange + ); + } + + private clearScheduledUpdate(): void { + if (this.timeoutId === undefined) return; + window.clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + + private updateAndSchedule = (): void => { + this.clearScheduledUpdate(); + if (!this.running) return; + + const remaining = getCountdownRemaining(this.expiresAt, this.now); + this.onUpdate(remaining); + if (remaining > 0 && document.visibilityState !== "hidden") { + this.timeoutId = window.setTimeout( + this.updateAndSchedule, + Math.min(1000, remaining) + ); + } + }; + + private handleVisibilityChange = (): void => { + if (document.visibilityState === "hidden") { + this.clearScheduledUpdate(); + return; + } + this.updateAndSchedule(); + }; +} diff --git a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/index.tsx b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/index.tsx index fd08baed3c..bbd07532ff 100644 --- a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/index.tsx +++ b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/index.tsx @@ -1,6 +1,6 @@ import { useAtomValue, useSetAtom } from "jotai"; import { DraftingCompass } from "lucide-react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { sendAdeActionResult } from "@src/api/tauri/agent"; import { DISPATCH_CATEGORY } from "@src/api/tauri/session"; @@ -14,6 +14,10 @@ import { PaletteBody, SpotlightShell } from "../../shell"; import { AgentControlInputTrailing } from "./AgentControlInputTrailing"; import { AgentControlStatus } from "./AgentControlStatus"; import { AgentControlToolbar } from "./AgentControlToolbar"; +import { + CountdownScheduler, + getCountdownRemaining, +} from "./countdownScheduler"; import { useAgentControlPalette } from "./useAgentControlPalette"; export type { AdeManagerSubmitDetail } from "./types"; @@ -28,20 +32,15 @@ const TOTAL_MS = 5 * 60 * 1000; function useCountdown(expiresAt: number) { const [remaining, setRemaining] = useState(() => - Math.max(0, expiresAt - Date.now()) + getCountdownRemaining(expiresAt) ); - const rafRef = useRef(null); + useEffect(() => { - const tick = () => { - const left = Math.max(0, expiresAt - Date.now()); - setRemaining(left); - if (left > 0) rafRef.current = requestAnimationFrame(tick); - }; - rafRef.current = requestAnimationFrame(tick); - return () => { - if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); - }; + const scheduler = new CountdownScheduler(expiresAt, setRemaining); + scheduler.start(); + return () => scheduler.stop(); }, [expiresAt]); + const seconds = Math.ceil(remaining / 1000); const pct = remaining / TOTAL_MS; const mins = Math.floor(seconds / 60); diff --git a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderConfig.ts b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderConfig.ts index 1bd38cecd4..2992548373 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderConfig.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderConfig.ts @@ -4,8 +4,6 @@ * Fetches provider configuration from Rust backend (single source of truth). * Caches configs in memory for the session duration. */ -import { useEffect, useState } from "react"; - import { rpc } from "@src/api/tauri/rpc"; import type { ProviderConfig, @@ -13,6 +11,7 @@ import type { ProviderProtocol, } from "@src/api/tauri/rpc/schemas/validation"; import type { ModelType } from "@src/api/types/keys"; +import { useAsyncResource } from "@src/hooks/async"; // ============================================ // Cache @@ -25,13 +24,20 @@ async function loadAllConfigs(): Promise> { if (configCache) return configCache; if (loadingPromise) return loadingPromise; - loadingPromise = rpc.validation.getAllProviderConfigs().then((result) => { + const promise = rpc.validation.getAllProviderConfigs().then((result) => { configCache = result; - loadingPromise = null; return result; }); - - return loadingPromise; + loadingPromise = promise; + void promise.then( + () => { + if (loadingPromise === promise) loadingPromise = null; + }, + () => { + if (loadingPromise === promise) loadingPromise = null; + } + ); + return promise; } // ============================================ @@ -87,36 +93,15 @@ export function useProviderConfig(modelType: ModelType | undefined): { loading: boolean; error: string | null; } { - const [allConfigs, setAllConfigs] = useState | null>(configCache); - const [loading, setLoading] = useState(!configCache); - const [error, setError] = useState(null); - - useEffect(() => { - // Already have cached data - no need to fetch - if (configCache) return; - - let cancelled = false; - loadAllConfigs() - .then((result) => { - if (!cancelled) { - setAllConfigs(result); - setLoading(false); - } - }) - .catch((err) => { - if (!cancelled) { - setError(err instanceof Error ? err.message : String(err)); - setLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, []); + const resource = useAsyncResource | null>({ + fetcher: loadAllConfigs, + initialData: configCache, + initialStatus: configCache ? "ready" : "idle", + scopeKey: "provider-configs", + }); + const allConfigs = resource.data; + const loading = resource.loading; + const error = resource.error; if (!modelType || loading) { return { config: null, loading, error }; diff --git a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts index f37031583a..796e8dc4cb 100644 --- a/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts +++ b/src/scaffold/WizardSystem/variants/KeyVault/hooks/useProviderRegistry.ts @@ -6,7 +6,7 @@ */ import type { TFunction } from "i18next"; import { useSetAtom } from "jotai"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { loadAvailableAgents } from "@src/api/services/availableAgents"; @@ -17,6 +17,7 @@ import type { ProviderProtocol, } from "@src/api/tauri/rpc/schemas/validation"; import { LOCAL_MODEL_PROVIDER } from "@src/api/types/keys"; +import { useAsyncResource } from "@src/hooks/async"; import { agentRegistryAtom } from "@src/store/session/agentRegistryAtom"; // ============================================ @@ -198,16 +199,23 @@ async function loadRegistry(): Promise { if (registryCache) return registryCache; if (loadingPromise) return loadingPromise; - loadingPromise = Promise.all([ + const promise = Promise.all([ loadAvailableAgents(), rpc.validation.getAvailableApiProviders(), ]).then(([agents, apiProviders]) => { registryCache = { agents, apiProviders }; - loadingPromise = null; return registryCache; }); - - return loadingPromise; + loadingPromise = promise; + void promise.then( + () => { + if (loadingPromise === promise) loadingPromise = null; + }, + () => { + if (loadingPromise === promise) loadingPromise = null; + } + ); + return promise; } // ============================================ @@ -514,53 +522,27 @@ export function useProviderRegistry( ): UseProviderRegistryResult { const { primaryOnly = false } = options; const { t } = useTranslation("integrations"); - const [data, setData] = useState(registryCache); - const [loading, setLoading] = useState(!registryCache); - const [error, setError] = useState(null); const setAgentRegistry = useSetAtom(agentRegistryAtom); + const resource = useAsyncResource({ + fetcher: loadRegistry, + initialData: registryCache, + initialStatus: registryCache ? "ready" : "idle", + scopeKey: "provider-registry", + }); + const data = resource.data; + const refreshResource = resource.refresh; useEffect(() => { - if (registryCache) { - setAgentRegistry(registryCache); - return; + if (resource.status === "ready" && data) { + setAgentRegistry(data); } + }, [data, resource.status, setAgentRegistry]); - let cancelled = false; - loadRegistry() - .then((result) => { - if (!cancelled) { - setData(result); - setAgentRegistry(result); - setLoading(false); - } - }) - .catch((err) => { - if (!cancelled) { - setError(err instanceof Error ? err.message : String(err)); - setLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [setAgentRegistry]); - - const reload = async () => { + const reload = useCallback(async () => { registryCache = null; loadingPromise = null; - setLoading(true); - setError(null); - try { - const result = await loadRegistry(); - setData(result); - setAgentRegistry(result); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }; + await refreshResource(); + }, [refreshResource]); const unifiedProviders = useMemo(() => { if (!data) return []; @@ -579,8 +561,8 @@ export function useProviderRegistry( apiProviders: [], unifiedProviders: [], modelTypeToProviderKey: {}, - loading, - error, + loading: resource.loading, + error: resource.error, reload, }; } @@ -590,8 +572,8 @@ export function useProviderRegistry( apiProviders: data.apiProviders, unifiedProviders, modelTypeToProviderKey, - loading, - error, + loading: resource.loading, + error: resource.error, reload, }; }