* Wrap desktop IPC calls in shared hooks * Unslop desktop IPC hook callsites * Redesign desktop IPC hooks around domains
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
|
||||
interface DesktopIpcErrorReport {
|
||||
toast: ToastApi;
|
||||
logLabel: string;
|
||||
message: string;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
interface DesktopIpcQueryErrorToastOptions {
|
||||
error: Error | null;
|
||||
logLabel: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface DesktopIpcErrorReporterInput {
|
||||
logLabel: string;
|
||||
message: string;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export function reportDesktopIpcError(input: DesktopIpcErrorReport): void {
|
||||
console.error(input.logLabel, input.error);
|
||||
input.toast.error(input.message);
|
||||
}
|
||||
|
||||
export function useDesktopIpcErrorReporter(): (input: DesktopIpcErrorReporterInput) => void {
|
||||
const toast = useToast();
|
||||
return useCallback(
|
||||
(input: DesktopIpcErrorReporterInput) => {
|
||||
reportDesktopIpcError({ ...input, toast });
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
}
|
||||
|
||||
export function useDesktopIpcQueryErrorToast(options: DesktopIpcQueryErrorToastOptions): void {
|
||||
const toast = useToast();
|
||||
const lastReportedErrorRef = useRef<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.error || options.error === lastReportedErrorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastReportedErrorRef.current = options.error;
|
||||
reportDesktopIpcError({
|
||||
toast,
|
||||
logLabel: options.logLabel,
|
||||
message: options.message,
|
||||
error: options.error,
|
||||
});
|
||||
}, [options.error, options.logLabel, options.message, toast]);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
type DesktopDaemonStatus,
|
||||
startDesktopDaemon,
|
||||
stopDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
executeDaemonManagementToggle,
|
||||
type DaemonManagementToggleResult,
|
||||
} from "@/desktop/daemon/daemon-management-toggle";
|
||||
import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error";
|
||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
type DesktopDaemonSettings = DesktopSettings["daemon"];
|
||||
|
||||
interface UseBuiltInDaemonManagementInput {
|
||||
daemonStatus: DesktopDaemonStatus | null;
|
||||
settings: DesktopDaemonSettings;
|
||||
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
|
||||
setStatus: (status: DesktopDaemonStatus) => void;
|
||||
refreshStatus: () => void;
|
||||
}
|
||||
|
||||
interface UseBuiltInDaemonManagementResult {
|
||||
isUpdating: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export function useBuiltInDaemonManagement(
|
||||
input: UseBuiltInDaemonManagementInput,
|
||||
): UseBuiltInDaemonManagementResult {
|
||||
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
|
||||
DaemonManagementToggleResult,
|
||||
Error
|
||||
>({
|
||||
mutationFn: () =>
|
||||
executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, {
|
||||
confirm: () =>
|
||||
confirmDialog({
|
||||
title: "Pause built-in daemon",
|
||||
message:
|
||||
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
|
||||
confirmLabel: "Pause and stop",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
}),
|
||||
persistSettings: (next) => updateSettings(next) as Promise<void>,
|
||||
startDaemon: startDesktopDaemon,
|
||||
stopDaemon: stopDesktopDaemon,
|
||||
}),
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: settings.manageBuiltInDaemon
|
||||
? "Built-in daemon management was paused, but Paseo could not stop the daemon."
|
||||
: "Unable to update built-in daemon management.",
|
||||
logLabel: "[Settings] Failed to update built-in daemon management",
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.kind === "cancelled") {
|
||||
return;
|
||||
}
|
||||
if (result.newStatus) {
|
||||
setStatus(result.newStatus);
|
||||
}
|
||||
refreshStatus();
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleDaemonManagement();
|
||||
}, [isUpdating, toggleDaemonManagement]);
|
||||
|
||||
return { isUpdating, toggle };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type DesktopDaemonLogs,
|
||||
type DesktopDaemonStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useDesktopIpcQueryErrorToast } from "@/desktop/hooks/desktop-ipc-error";
|
||||
|
||||
const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const;
|
||||
|
||||
@@ -19,16 +20,22 @@ export function useDaemonStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
|
||||
const query = useQuery<DaemonStatusData>({
|
||||
const query = useQuery<DaemonStatusData, Error>({
|
||||
queryKey: DAEMON_STATUS_QUERY_KEY,
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||
return { status, logs };
|
||||
},
|
||||
});
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: query.error,
|
||||
message: "Unable to load desktop daemon status.",
|
||||
logLabel: "[DesktopDaemon] Failed to load daemon status",
|
||||
});
|
||||
|
||||
const setStatus = useCallback(
|
||||
(status: DesktopDaemonStatus) => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCliInstall, useSkillsInstall } from "./use-install-status";
|
||||
|
||||
const toast = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
show: vi.fn(),
|
||||
copied: vi.fn(),
|
||||
}));
|
||||
|
||||
const desktopDaemon = vi.hoisted(() => ({
|
||||
getCliInstallStatus: vi.fn(),
|
||||
getSkillsInstallStatus: vi.fn(),
|
||||
installCli: vi.fn(),
|
||||
installSkills: vi.fn(),
|
||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/toast-context", () => ({
|
||||
useToast: () => toast,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/daemon/desktop-daemon", () => desktopDaemon);
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderDesktopHook<TResult>(callback: () => TResult) {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
return renderHook(callback, { wrapper });
|
||||
}
|
||||
|
||||
describe("useCliInstall", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: true });
|
||||
desktopDaemon.installCli.mockResolvedValue({ installed: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads CLI install status", async () => {
|
||||
const { result } = renderDesktopHook(() => useCliInstall());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ installed: true });
|
||||
});
|
||||
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts and exposes CLI install errors", async () => {
|
||||
const error = new Error("Missing IPC handler");
|
||||
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false });
|
||||
desktopDaemon.installCli.mockRejectedValue(error);
|
||||
const { result } = renderDesktopHook(() => useCliInstall());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ installed: false });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.install();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(error);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI.");
|
||||
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install CLI", error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSkillsInstall", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
|
||||
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads skills install status", async () => {
|
||||
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ installed: true });
|
||||
});
|
||||
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts and exposes skills install errors", async () => {
|
||||
const error = new Error("Missing IPC handler");
|
||||
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
|
||||
desktopDaemon.installSkills.mockRejectedValue(error);
|
||||
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ installed: false });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.install();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(error);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
|
||||
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getCliInstallStatus,
|
||||
getSkillsInstallStatus,
|
||||
installCli,
|
||||
installSkills,
|
||||
shouldUseDesktopDaemon,
|
||||
type InstallStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
useDesktopIpcErrorReporter,
|
||||
useDesktopIpcQueryErrorToast,
|
||||
} from "@/desktop/hooks/desktop-ipc-error";
|
||||
|
||||
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
|
||||
const SKILLS_INSTALL_STATUS_QUERY_KEY = [
|
||||
"desktop",
|
||||
"integrations",
|
||||
"skills-install-status",
|
||||
] as const;
|
||||
|
||||
interface DesktopInstallHookResult {
|
||||
status: InstallStatus | null;
|
||||
isLoading: boolean;
|
||||
isInstalling: boolean;
|
||||
error: Error | null;
|
||||
install: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useCliInstall(): DesktopInstallHookResult {
|
||||
const queryClient = useQueryClient();
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
|
||||
const statusQuery = useQuery<InstallStatus, Error>({
|
||||
queryKey: CLI_INSTALL_STATUS_QUERY_KEY,
|
||||
queryFn: getCliInstallStatus,
|
||||
enabled,
|
||||
retry: false,
|
||||
});
|
||||
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: statusQuery.error,
|
||||
message: "Unable to check CLI install status.",
|
||||
logLabel: "[Integrations] Failed to load CLI status",
|
||||
});
|
||||
|
||||
const installMutation = useMutation<InstallStatus, Error>({
|
||||
mutationFn: installCli,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to install the Paseo CLI.",
|
||||
logLabel: "[Integrations] Failed to install CLI",
|
||||
});
|
||||
},
|
||||
onSuccess: (nextStatus) => {
|
||||
queryClient.setQueryData<InstallStatus>(CLI_INSTALL_STATUS_QUERY_KEY, nextStatus);
|
||||
void queryClient.invalidateQueries({ queryKey: CLI_INSTALL_STATUS_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return {
|
||||
status: installStatus ?? null,
|
||||
isLoading,
|
||||
isInstalling,
|
||||
error: statusError ?? installError ?? null,
|
||||
install,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSkillsInstall(): DesktopInstallHookResult {
|
||||
const queryClient = useQueryClient();
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
|
||||
const statusQuery = useQuery<InstallStatus, Error>({
|
||||
queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
|
||||
queryFn: getSkillsInstallStatus,
|
||||
enabled,
|
||||
retry: false,
|
||||
});
|
||||
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: statusQuery.error,
|
||||
message: "Unable to check orchestration skills install status.",
|
||||
logLabel: "[Integrations] Failed to load skills status",
|
||||
});
|
||||
|
||||
const installMutation = useMutation<InstallStatus, Error>({
|
||||
mutationFn: installSkills,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to install orchestration skills.",
|
||||
logLabel: "[Integrations] Failed to install skills",
|
||||
});
|
||||
},
|
||||
onSuccess: (nextStatus) => {
|
||||
queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
|
||||
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return {
|
||||
status: installStatus ?? null,
|
||||
isLoading,
|
||||
isInstalling,
|
||||
error: statusError ?? installError ?? null,
|
||||
install,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user