diff --git a/.env.example b/.env.example index b0747a61..e12c4c67 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,6 @@ APPLE_CODESIGN_KEYCHAIN_PASSWORD="xxx" VITE_POSTHOG_API_KEY=xxx VITE_POSTHOG_API_HOST=xxx -VITE_POSTHOG_UI_HOST=xxx \ No newline at end of file +VITE_POSTHOG_UI_HOST=xxx + +VITE_DEV_ERROR_TOASTS=true diff --git a/apps/array/package.json b/apps/array/package.json index bc00d5de..b58837da 100644 --- a/apps/array/package.json +++ b/apps/array/package.json @@ -138,6 +138,7 @@ "radix-themes-tw": "0.2.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^6.0.0", "react-hook-form": "^7.64.0", "react-hotkeys-hook": "^4.4.4", "react-markdown": "^10.1.0", diff --git a/apps/array/src/main/di/container.ts b/apps/array/src/main/di/container.ts index 1c7ba0e1..52d7176f 100644 --- a/apps/array/src/main/di/container.ts +++ b/apps/array/src/main/di/container.ts @@ -14,6 +14,7 @@ import { ShellService } from "../services/shell/service.js"; import { TaskLinkService } from "../services/task-link/service.js"; import { UIService } from "../services/ui/service.js"; import { UpdatesService } from "../services/updates/service.js"; +import { UserNotificationService } from "../services/user-notification/service.js"; import { WorkspaceService } from "../services/workspace/service.js"; import { MAIN_TOKENS } from "./tokens.js"; @@ -35,4 +36,5 @@ container.bind(MAIN_TOKENS.ShellService).to(ShellService); container.bind(MAIN_TOKENS.UIService).to(UIService); container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService); container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService); +container.bind(MAIN_TOKENS.UserNotificationService).to(UserNotificationService); container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService); diff --git a/apps/array/src/main/di/tokens.ts b/apps/array/src/main/di/tokens.ts index b6822ba7..101d337c 100644 --- a/apps/array/src/main/di/tokens.ts +++ b/apps/array/src/main/di/tokens.ts @@ -21,4 +21,5 @@ export const MAIN_TOKENS = Object.freeze({ UpdatesService: Symbol.for("Main.UpdatesService"), TaskLinkService: Symbol.for("Main.TaskLinkService"), WorkspaceService: Symbol.for("Main.WorkspaceService"), + UserNotificationService: Symbol.for("Main.UserNotificationService"), }); diff --git a/apps/array/src/main/index.ts b/apps/array/src/main/index.ts index b7f775b5..e6ee5466 100644 --- a/apps/array/src/main/index.ts +++ b/apps/array/src/main/index.ts @@ -13,6 +13,10 @@ import { mkdirSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { initializeMainErrorHandling } from "./lib/error-handling.js"; + +initializeMainErrorHandling(); + import { createIPCHandler } from "@posthog/electron-trpc/main"; import { app, diff --git a/apps/array/src/main/lib/error-handling.ts b/apps/array/src/main/lib/error-handling.ts new file mode 100644 index 00000000..94b27805 --- /dev/null +++ b/apps/array/src/main/lib/error-handling.ts @@ -0,0 +1,20 @@ +import { ipcMain } from "electron"; +import { logger } from "./logger.js"; + +export function initializeMainErrorHandling(): void { + process.on("uncaughtException", (error) => { + logger.error("Uncaught exception", error); + }); + + process.on("unhandledRejection", (reason) => { + const error = reason instanceof Error ? reason : new Error(String(reason)); + logger.error("Unhandled rejection", error); + }); + + ipcMain.on( + "preload-error", + (_, error: { message: string; stack?: string }) => { + logger.error("Preload error", error); + }, + ); +} diff --git a/apps/array/src/main/lib/logger.ts b/apps/array/src/main/lib/logger.ts index 349797ec..50a3002a 100644 --- a/apps/array/src/main/lib/logger.ts +++ b/apps/array/src/main/lib/logger.ts @@ -1,37 +1,15 @@ +import type { Logger, ScopedLogger } from "@shared/lib/create-logger.js"; +import { createLogger } from "@shared/lib/create-logger.js"; import { app } from "electron"; import log from "electron-log/main"; -// Initialize IPC transport to forward main process logs to renderer dev tools log.initialize(); -// Set levels - use debug in dev (check NODE_ENV since app.isPackaged may not be ready) const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; const level = isDev ? "debug" : "info"; log.transports.file.level = level; log.transports.console.level = level; -// IPC transport needs level set separately log.transports.ipc.level = level; -export const logger = { - info: (message: string, ...args: unknown[]) => log.info(message, ...args), - warn: (message: string, ...args: unknown[]) => log.warn(message, ...args), - error: (message: string, ...args: unknown[]) => log.error(message, ...args), - debug: (message: string, ...args: unknown[]) => log.debug(message, ...args), - - scope: (name: string) => { - const scoped = log.scope(name); - return { - info: (message: string, ...args: unknown[]) => - scoped.info(message, ...args), - warn: (message: string, ...args: unknown[]) => - scoped.warn(message, ...args), - error: (message: string, ...args: unknown[]) => - scoped.error(message, ...args), - debug: (message: string, ...args: unknown[]) => - scoped.debug(message, ...args), - }; - }, -}; - -export type Logger = typeof logger; -export type ScopedLogger = ReturnType; +export const logger = createLogger(log); +export type { Logger, ScopedLogger }; diff --git a/apps/array/src/main/preload.ts b/apps/array/src/main/preload.ts index 88f4dfa9..3ca78264 100644 --- a/apps/array/src/main/preload.ts +++ b/apps/array/src/main/preload.ts @@ -1,6 +1,23 @@ import { exposeElectronTRPC } from "@posthog/electron-trpc/main"; +import { ipcRenderer } from "electron"; import "electron-log/preload"; +// No TRPC available, so just use IPC +process.on("uncaughtException", (error) => { + ipcRenderer.send("preload-error", { + message: error.message, + stack: error.stack, + }); +}); + +process.on("unhandledRejection", (reason) => { + const error = reason instanceof Error ? reason : new Error(String(reason)); + ipcRenderer.send("preload-error", { + message: error.message, + stack: error.stack, + }); +}); + process.once("loaded", async () => { exposeElectronTRPC(); }); diff --git a/apps/array/src/main/services/user-notification/schemas.ts b/apps/array/src/main/services/user-notification/schemas.ts new file mode 100644 index 00000000..f4228e6b --- /dev/null +++ b/apps/array/src/main/services/user-notification/schemas.ts @@ -0,0 +1,15 @@ +export const UserNotificationEvent = { + Notify: "notify", +} as const; + +export type NotificationSeverity = "error" | "warning" | "info"; + +export interface UserNotificationPayload { + severity: NotificationSeverity; + title: string; + description?: string; +} + +export interface UserNotificationEvents { + [UserNotificationEvent.Notify]: UserNotificationPayload; +} diff --git a/apps/array/src/main/services/user-notification/service.ts b/apps/array/src/main/services/user-notification/service.ts new file mode 100644 index 00000000..808153ad --- /dev/null +++ b/apps/array/src/main/services/user-notification/service.ts @@ -0,0 +1,46 @@ +import { app } from "electron"; +import { injectable, postConstruct } from "inversify"; +import { logger } from "../../lib/logger.js"; +import { TypedEventEmitter } from "../../lib/typed-event-emitter.js"; +import { + UserNotificationEvent, + type UserNotificationEvents, +} from "./schemas.js"; + +const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; +const devErrorToastsEnabled = + isDev && process.env.VITE_DEV_ERROR_TOASTS !== "false"; + +@injectable() +export class UserNotificationService extends TypedEventEmitter { + @postConstruct() + init(): void { + if (devErrorToastsEnabled) { + logger.setDevToastEmitter((title, desc) => this.error(title, desc)); + } + } + + error(title: string, description?: string): void { + this.emit(UserNotificationEvent.Notify, { + severity: "error", + title, + description, + }); + } + + warning(title: string, description?: string): void { + this.emit(UserNotificationEvent.Notify, { + severity: "warning", + title, + description, + }); + } + + info(title: string, description?: string): void { + this.emit(UserNotificationEvent.Notify, { + severity: "info", + title, + description, + }); + } +} diff --git a/apps/array/src/main/trpc/router.ts b/apps/array/src/main/trpc/router.ts index cc19c226..6749e23b 100644 --- a/apps/array/src/main/trpc/router.ts +++ b/apps/array/src/main/trpc/router.ts @@ -16,6 +16,7 @@ import { secureStoreRouter } from "./routers/secure-store.js"; import { shellRouter } from "./routers/shell.js"; import { uiRouter } from "./routers/ui.js"; import { updatesRouter } from "./routers/updates.js"; +import { userNotificationRouter } from "./routers/user-notification.js"; import { workspaceRouter } from "./routers/workspace.js"; import { router } from "./trpc.js"; @@ -37,6 +38,7 @@ export const trpcRouter = router({ shell: shellRouter, ui: uiRouter, updates: updatesRouter, + userNotification: userNotificationRouter, deepLink: deepLinkRouter, workspace: workspaceRouter, }); diff --git a/apps/array/src/main/trpc/routers/user-notification.ts b/apps/array/src/main/trpc/routers/user-notification.ts new file mode 100644 index 00000000..25083d43 --- /dev/null +++ b/apps/array/src/main/trpc/routers/user-notification.ts @@ -0,0 +1,20 @@ +import { container } from "../../di/container.js"; +import { MAIN_TOKENS } from "../../di/tokens.js"; +import { UserNotificationEvent } from "../../services/user-notification/schemas.js"; +import type { UserNotificationService } from "../../services/user-notification/service.js"; +import { publicProcedure, router } from "../trpc.js"; + +const getService = () => + container.get(MAIN_TOKENS.UserNotificationService); + +export const userNotificationRouter = router({ + onNotify: publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(UserNotificationEvent.Notify, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), +}); diff --git a/apps/array/src/renderer/App.tsx b/apps/array/src/renderer/App.tsx index c4595084..75b21e0a 100644 --- a/apps/array/src/renderer/App.tsx +++ b/apps/array/src/renderer/App.tsx @@ -1,10 +1,10 @@ +import { ErrorBoundary } from "@components/ErrorBoundary"; import { MainLayout } from "@components/MainLayout"; import { AuthScreen } from "@features/auth/components/AuthScreen"; import { useAuthStore } from "@features/auth/stores/authStore"; +import { useUserNotifications } from "@hooks/useUserNotifications"; import { Flex, Spinner, Text } from "@radix-ui/themes"; import { initializePostHog } from "@renderer/lib/analytics"; -import { trpcVanilla } from "@renderer/trpc/client"; -import { toast } from "@utils/toast"; import { useEffect, useState } from "react"; function App() { @@ -16,15 +16,8 @@ function App() { initializePostHog(); }, []); - // Global workspace error listener for toasts - useEffect(() => { - const subscription = trpcVanilla.workspace.onError.subscribe(undefined, { - onData: (data) => { - toast.error("Workspace error", { description: data.message }); - }, - }); - return () => subscription.unsubscribe(); - }, []); + // Global notification listener - handles all main process notifications + useUserNotifications(); useEffect(() => { initializeOAuth().finally(() => setIsLoading(false)); @@ -41,7 +34,11 @@ function App() { ); } - return isAuthenticated ? : ; + return ( + + {isAuthenticated ? : } + + ); } export default App; diff --git a/apps/array/src/api/posthogClient.ts b/apps/array/src/renderer/api/posthogClient.ts similarity index 98% rename from apps/array/src/api/posthogClient.ts rename to apps/array/src/renderer/api/posthogClient.ts index 23c1acc8..ff806f75 100644 --- a/apps/array/src/api/posthogClient.ts +++ b/apps/array/src/renderer/api/posthogClient.ts @@ -1,9 +1,9 @@ +import { buildApiFetcher } from "@api/fetcher"; +import { createApiClient, type Schemas } from "@api/generated"; import type { AgentEvent } from "@posthog/agent"; import { logger } from "@renderer/lib/logger"; import type { Task, TaskRun } from "@shared/types"; import type { StoredLogEntry } from "@shared/types/session-events"; -import { buildApiFetcher } from "./fetcher"; -import { createApiClient, type Schemas } from "./generated"; const log = logger.scope("posthog-client"); diff --git a/apps/array/src/renderer/components/ErrorBoundary.tsx b/apps/array/src/renderer/components/ErrorBoundary.tsx new file mode 100644 index 00000000..2cec73ef --- /dev/null +++ b/apps/array/src/renderer/components/ErrorBoundary.tsx @@ -0,0 +1,56 @@ +import { Button, Card, Flex, Text } from "@radix-ui/themes"; +import { logger } from "@renderer/lib/logger"; +import type { ReactNode } from "react"; +import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +function DefaultFallback({ + error, + onReset, +}: { + error: Error; + onReset: () => void; +}) { + return ( + + + + + Something went wrong + + + {error.message} + + + + + + ); +} + +export function ErrorBoundary({ children, fallback }: Props) { + return ( + + fallback ?? ( + + ) + } + onError={(error, info) => { + logger.error("React error boundary caught error", { + error: error.message, + stack: error.stack, + componentStack: info.componentStack, + }); + }} + > + {children} + + ); +} diff --git a/apps/array/src/renderer/features/auth/stores/authStore.ts b/apps/array/src/renderer/features/auth/stores/authStore.ts index 987167a9..9bb564ac 100644 --- a/apps/array/src/renderer/features/auth/stores/authStore.ts +++ b/apps/array/src/renderer/features/auth/stores/authStore.ts @@ -1,4 +1,4 @@ -import { PostHogAPIClient } from "@api/posthogClient"; +import { PostHogAPIClient } from "@renderer/api/posthogClient"; import { identifyUser, resetUser, track } from "@renderer/lib/analytics"; import { electronStorage } from "@renderer/lib/electronStorage"; import { logger } from "@renderer/lib/logger"; diff --git a/apps/array/src/renderer/hooks/useAuthenticatedMutation.ts b/apps/array/src/renderer/hooks/useAuthenticatedMutation.ts index cfa84518..8a96b90c 100644 --- a/apps/array/src/renderer/hooks/useAuthenticatedMutation.ts +++ b/apps/array/src/renderer/hooks/useAuthenticatedMutation.ts @@ -1,5 +1,5 @@ -import type { PostHogAPIClient } from "@api/posthogClient"; import { useAuthStore } from "@features/auth/stores/authStore"; +import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import type { UseMutationOptions, UseMutationResult, diff --git a/apps/array/src/renderer/hooks/useAuthenticatedQuery.ts b/apps/array/src/renderer/hooks/useAuthenticatedQuery.ts index c12cc7c2..8ea15774 100644 --- a/apps/array/src/renderer/hooks/useAuthenticatedQuery.ts +++ b/apps/array/src/renderer/hooks/useAuthenticatedQuery.ts @@ -1,5 +1,5 @@ -import type { PostHogAPIClient } from "@api/posthogClient"; import { useAuthStore } from "@features/auth/stores/authStore"; +import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import type { QueryKey, UseQueryOptions, diff --git a/apps/array/src/renderer/hooks/useUserNotifications.ts b/apps/array/src/renderer/hooks/useUserNotifications.ts new file mode 100644 index 00000000..0482b762 --- /dev/null +++ b/apps/array/src/renderer/hooks/useUserNotifications.ts @@ -0,0 +1,20 @@ +import { trpcReact } from "@renderer/trpc/client"; +import { toast } from "@utils/toast"; + +export function useUserNotifications() { + trpcReact.userNotification.onNotify.useSubscription(undefined, { + onData: ({ severity, title, description }) => { + switch (severity) { + case "error": + toast.error(title, { description }); + break; + case "warning": + toast.warning(title, { description }); + break; + case "info": + toast.info(title, description); + break; + } + }, + }); +} diff --git a/apps/array/src/renderer/lib/error-handling.ts b/apps/array/src/renderer/lib/error-handling.ts new file mode 100644 index 00000000..ec135ba9 --- /dev/null +++ b/apps/array/src/renderer/lib/error-handling.ts @@ -0,0 +1,50 @@ +import { formatArgsToString } from "@shared/utils/format"; +import { toast } from "@utils/toast"; +import { IS_DEV } from "@/constants/environment"; +import { logger } from "./logger"; + +const devErrorToastsEnabled = + IS_DEV && import.meta.env.VITE_DEV_ERROR_TOASTS !== "false"; + +export function initializeRendererErrorHandling(): void { + if (devErrorToastsEnabled) { + interceptConsole(); + } + + window.addEventListener("error", (event) => { + const message = event.error?.message || event.message || "Unknown error"; + logger.error("Uncaught error", event.error || message); + if (!devErrorToastsEnabled) { + toast.error("An unexpected error occurred", { description: message }); + } + }); + + window.addEventListener("unhandledrejection", (event) => { + const message = + event.reason instanceof Error + ? event.reason.message + : String(event.reason || "Unknown error"); + logger.error("Unhandled rejection", event.reason); + if (!devErrorToastsEnabled) { + toast.error("An unexpected error occurred", { description: message }); + } + }); +} + +function interceptConsole(): void { + const { error: originalError, warn: originalWarn } = console; + + console.error = (...args: unknown[]) => { + originalError.apply(console, args); + toast.error("[DEV] Console error", { + description: formatArgsToString(args), + }); + }; + + console.warn = (...args: unknown[]) => { + originalWarn.apply(console, args); + toast.warning("[DEV] Console warning", { + description: formatArgsToString(args), + }); + }; +} diff --git a/apps/array/src/renderer/lib/logger.ts b/apps/array/src/renderer/lib/logger.ts index 3f7ad109..6023cb16 100644 --- a/apps/array/src/renderer/lib/logger.ts +++ b/apps/array/src/renderer/lib/logger.ts @@ -1,28 +1,17 @@ +import type { Logger, ScopedLogger } from "@shared/lib/create-logger"; +import { createLogger } from "@shared/lib/create-logger"; +import { toast } from "@utils/toast"; import log from "electron-log/renderer"; +import { IS_DEV } from "@/constants/environment"; -// Ensure logs appear in dev tools console log.transports.console.level = "debug"; -export const logger = { - info: (message: string, ...args: unknown[]) => log.info(message, ...args), - warn: (message: string, ...args: unknown[]) => log.warn(message, ...args), - error: (message: string, ...args: unknown[]) => log.error(message, ...args), - debug: (message: string, ...args: unknown[]) => log.debug(message, ...args), +const devErrorToastsEnabled = + IS_DEV && import.meta.env.VITE_DEV_ERROR_TOASTS !== "false"; - scope: (name: string) => { - const scoped = log.scope(name); - return { - info: (message: string, ...args: unknown[]) => - scoped.info(message, ...args), - warn: (message: string, ...args: unknown[]) => - scoped.warn(message, ...args), - error: (message: string, ...args: unknown[]) => - scoped.error(message, ...args), - debug: (message: string, ...args: unknown[]) => - scoped.debug(message, ...args), - }; - }, -}; +const emitToast = devErrorToastsEnabled + ? (title: string, description?: string) => toast.error(title, { description }) + : undefined; -export type Logger = typeof logger; -export type ScopedLogger = ReturnType; +export const logger = createLogger(log, emitToast); +export type { Logger, ScopedLogger }; diff --git a/apps/array/src/renderer/lib/queryClient.ts b/apps/array/src/renderer/lib/queryClient.ts index 07d7f15c..c4179eb9 100644 --- a/apps/array/src/renderer/lib/queryClient.ts +++ b/apps/array/src/renderer/lib/queryClient.ts @@ -1,6 +1,14 @@ -import { QueryClient } from "@tanstack/react-query"; +import { MutationCache, QueryClient } from "@tanstack/react-query"; +import { toast } from "@utils/toast"; export const queryClient = new QueryClient({ + mutationCache: new MutationCache({ + onError: (error) => { + const message = + error instanceof Error ? error.message : "An error occurred"; + toast.error("Operation failed", { description: message }); + }, + }), defaultOptions: { queries: { staleTime: 1000 * 60 * 5, diff --git a/apps/array/src/renderer/main.tsx b/apps/array/src/renderer/main.tsx index e69c147e..dcaa0563 100644 --- a/apps/array/src/renderer/main.tsx +++ b/apps/array/src/renderer/main.tsx @@ -2,10 +2,14 @@ import "reflect-metadata"; import "@radix-ui/themes/styles.css"; import { Providers } from "@components/Providers"; import App from "@renderer/App"; +import { initializeRendererErrorHandling } from "@renderer/lib/error-handling"; import React from "react"; import ReactDOM from "react-dom/client"; import "./styles/globals.css"; +// Initialize error handling early, before React renders +initializeRendererErrorHandling(); + document.title = import.meta.env.DEV ? "Array (Development)" : "Array"; const rootElement = document.getElementById("root"); diff --git a/apps/array/src/renderer/sagas/task/task-creation.ts b/apps/array/src/renderer/sagas/task/task-creation.ts index dff3e1bc..c9198242 100644 --- a/apps/array/src/renderer/sagas/task/task-creation.ts +++ b/apps/array/src/renderer/sagas/task/task-creation.ts @@ -1,7 +1,7 @@ -import type { PostHogAPIClient } from "@api/posthogClient"; import { buildPromptBlocks } from "@features/editor/utils/prompt-builder"; import { getSessionActions } from "@features/sessions/stores/sessionStore"; import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore"; +import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { logger } from "@renderer/lib/logger"; import { useTaskDirectoryStore } from "@renderer/stores/taskDirectoryStore"; import { trpcVanilla } from "@renderer/trpc"; diff --git a/apps/array/src/renderer/types/electron.d.ts b/apps/array/src/renderer/types/electron.d.ts index 322a3631..c1b3ad48 100644 --- a/apps/array/src/renderer/types/electron.d.ts +++ b/apps/array/src/renderer/types/electron.d.ts @@ -1,3 +1 @@ import "@main/services/types"; - -// No legacy IPC interfaces - all communication now uses tRPC diff --git a/apps/array/src/renderer/utils/toast.tsx b/apps/array/src/renderer/utils/toast.tsx index d869c605..6be17067 100644 --- a/apps/array/src/renderer/utils/toast.tsx +++ b/apps/array/src/renderer/utils/toast.tsx @@ -1,16 +1,88 @@ -import { CheckIcon, InfoIcon, WarningIcon, XIcon } from "@phosphor-icons/react"; -import { Card, Flex, Spinner, Text } from "@radix-ui/themes"; +import { + CheckIcon, + Copy, + InfoIcon, + WarningIcon, + X, + XIcon, +} from "@phosphor-icons/react"; +import { + Box, + Button, + Card, + Dialog, + Flex, + IconButton, + Inset, + Spinner, + Text, + Tooltip, +} from "@radix-ui/themes"; + +import { useMemo, useState } from "react"; import { toast as sonnerToast } from "sonner"; +function formatErrorDescription(description: string): { + summary: string; + details: string; + isStructured: boolean; +} { + if (!description) { + return { summary: "", details: "", isStructured: false }; + } + + const trimmed = description.trim(); + if (!trimmed) { + return { summary: "", details: "", isStructured: false }; + } + + const tryParse = (input: string): unknown | null => { + try { + return JSON.parse(input); + } catch { + return null; + } + }; + + const parsedDirect = tryParse(trimmed); + if (parsedDirect !== null) { + const pretty = JSON.stringify(parsedDirect, null, 2); + return { summary: pretty, details: pretty, isStructured: true }; + } + + const unwrapped = + trimmed.startsWith("(") && trimmed.endsWith(")") + ? trimmed.slice(1, -1).trim() + : trimmed; + + const parsedUnwrapped = tryParse(unwrapped); + if (parsedUnwrapped !== null) { + const pretty = JSON.stringify(parsedUnwrapped, null, 2); + return { summary: pretty, details: pretty, isStructured: true }; + } + + const asIs = description.replace(/\r\n/g, "\n"); + return { summary: asIs, details: asIs, isStructured: false }; +} + interface ToastProps { - id: string | number; type: "loading" | "success" | "error" | "info" | "warning"; title: string; description?: string; + dismissable?: boolean; } function ToastComponent(props: ToastProps) { - const { type, title, description } = props; + const { type, title, description, dismissable = false } = props; + const [detailsOpen, setDetailsOpen] = useState(false); + + const formattedDescription = useMemo(() => { + if (!description) { + return null; + } + + return formatErrorDescription(description); + }, [description]); const getIcon = () => { switch (type) { @@ -28,26 +100,147 @@ function ToastComponent(props: ToastProps) { }; return ( - - - - {getIcon()} - - - - {title} - - {description && ( - - {description} + + + + + {getIcon()} + + + + {title} + + {dismissable && ( + )} + + {formattedDescription && ( + + + {formattedDescription.isStructured || + type === "error" || + type === "warning" ? ( + + + {formattedDescription.summary} + + + ) : ( + + {formattedDescription.summary} + + )} + + {(type === "error" || type === "warning") && ( + + + + + + + Full details + + + {title} + + { + const toCopy = `${title}\n\n${formattedDescription.details}`; + await navigator.clipboard.writeText(toCopy); + toast.success("Copied to clipboard"); + }} + > + + + + + + + {formattedDescription.details} + + + + + )} + + + )} ); @@ -55,13 +248,8 @@ function ToastComponent(props: ToastProps) { export const toast = { loading: (title: string, description?: string) => { - return sonnerToast.custom((id) => ( - + return sonnerToast.custom(() => ( + )); }, @@ -70,12 +258,12 @@ export const toast = { options?: { description?: string; id?: string | number }, ) => { return sonnerToast.custom( - (id) => ( + () => ( ), { id: options?.id }, @@ -87,25 +275,25 @@ export const toast = { options?: { description?: string; id?: string | number }, ) => { return sonnerToast.custom( - (id) => ( + () => ( ), - { id: options?.id }, + { id: options?.id, duration: Number.POSITIVE_INFINITY }, ); }, info: (title: string, description?: string) => { - return sonnerToast.custom((id) => ( + return sonnerToast.custom(() => ( )); }, @@ -115,15 +303,18 @@ export const toast = { options?: { description?: string; id?: string | number; duration?: number }, ) => { return sonnerToast.custom( - (id) => ( + () => ( ), - { id: options?.id, duration: options?.duration }, + { + id: options?.id, + duration: options?.duration ?? Number.POSITIVE_INFINITY, + }, ); }, }; diff --git a/apps/array/src/shared/lib/create-logger.ts b/apps/array/src/shared/lib/create-logger.ts new file mode 100644 index 00000000..73de8ccd --- /dev/null +++ b/apps/array/src/shared/lib/create-logger.ts @@ -0,0 +1,66 @@ +import { formatErrorDescription } from "@shared/utils/format"; + +type LogFn = (message: string, ...args: unknown[]) => void; + +interface BaseLog { + info: LogFn; + warn: LogFn; + error: LogFn; + debug: LogFn; + scope: (name: string) => { + info: LogFn; + warn: LogFn; + error: LogFn; + debug: LogFn; + }; +} + +export interface ScopedLogger { + info: LogFn; + warn: LogFn; + error: LogFn; + debug: LogFn; + scope: (name: string) => ScopedLogger; +} + +export interface Logger extends ScopedLogger { + setDevToastEmitter: (emitter: DevToastEmitter | undefined) => void; +} + +export type DevToastEmitter = (title: string, description?: string) => void; + +export function createLogger( + log: BaseLog, + initialEmitter?: DevToastEmitter, +): Logger { + let emitToast = initialEmitter; + + const createScopedLogger = ( + scoped: { info: LogFn; warn: LogFn; error: LogFn; debug: LogFn }, + name: string, + ): ScopedLogger => ({ + info: scoped.info, + warn: scoped.warn, + debug: scoped.debug, + error: (message, ...args) => { + scoped.error(message, ...args); + emitToast?.(`[DEV] [${name}] ${message}`, formatErrorDescription(args)); + }, + scope: (subName) => + createScopedLogger(log.scope(`${name}:${subName}`), `${name}:${subName}`), + }); + + return { + info: log.info, + warn: log.warn, + debug: log.debug, + error: (message, ...args) => { + log.error(message, ...args); + emitToast?.(`[DEV] ${message}`, formatErrorDescription(args)); + }, + scope: (name) => createScopedLogger(log.scope(name), name), + setDevToastEmitter: (emitter) => { + emitToast = emitter; + }, + }; +} diff --git a/apps/array/src/shared/utils/format.ts b/apps/array/src/shared/utils/format.ts new file mode 100644 index 00000000..0e3551fc --- /dev/null +++ b/apps/array/src/shared/utils/format.ts @@ -0,0 +1,70 @@ +/** + * Formats an array of arguments into a string description of the error. + * @param args - The arguments to format. + * @returns The formatted string description of the error. + */ +export function formatErrorDescription(args: unknown[]): string | undefined { + if (args.length === 0) return undefined; + const first = args[0]; + if (first instanceof Error) return first.message; + if (typeof first === "string") return first; + if (first !== null && first !== undefined) { + try { + return JSON.stringify(first); + } catch { + return String(first); + } + } + return undefined; +} + +function formatConsoleArg(arg: unknown): string { + if (arg instanceof Error) { + return arg.stack || arg.message; + } + + if (typeof arg === "string") { + return arg; + } + + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } +} + +function formatConsoleArgsWithSubstitutions(args: unknown[]): string { + if (args.length === 0) { + return ""; + } + + const first = args[0]; + if (typeof first !== "string") { + return args.map(formatConsoleArg).join(" "); + } + + let template = first; + let nextArgIndex = 1; + + template = template.replace(/%[sdifoO]/g, () => { + if (nextArgIndex >= args.length) { + return ""; + } + + const replacement = formatConsoleArg(args[nextArgIndex]); + nextArgIndex += 1; + return replacement; + }); + + const rest = args.slice(nextArgIndex).map(formatConsoleArg); + return rest.length > 0 ? `${template} ${rest.join(" ")}` : template; +} + +export function formatArgsToString(args: unknown[], maxLength = 5000): string { + const formatted = formatConsoleArgsWithSubstitutions(args); + + return formatted.length > maxLength + ? `${formatted.slice(0, maxLength)}\n… (truncated)` + : formatted; +} diff --git a/apps/array/src/vite-env.d.ts b/apps/array/src/vite-env.d.ts index 41c10685..d8cd056b 100644 --- a/apps/array/src/vite-env.d.ts +++ b/apps/array/src/vite-env.d.ts @@ -9,6 +9,7 @@ interface ImportMetaEnv { readonly VITE_POSTHOG_API_KEY?: string; readonly VITE_POSTHOG_API_HOST?: string; readonly VITE_POSTHOG_UI_HOST?: string; + readonly VITE_DEV_ERROR_TOASTS?: string; } interface ImportMeta { diff --git a/apps/array/vite.main.config.mts b/apps/array/vite.main.config.mts index d14ddf02..104b234f 100644 --- a/apps/array/vite.main.config.mts +++ b/apps/array/vite.main.config.mts @@ -19,6 +19,7 @@ function _getBuildDate(): string { } const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const _monorepoRoot = path.resolve(__dirname, "../.."); /** * Custom Vite plugin to fix circular __filename references in bundled ESM packages. diff --git a/apps/array/vite.preload.config.mts b/apps/array/vite.preload.config.mts index 93f99676..7545698f 100644 --- a/apps/array/vite.preload.config.mts +++ b/apps/array/vite.preload.config.mts @@ -5,8 +5,10 @@ import tsconfigPaths from "vite-tsconfig-paths"; import { autoServicesPlugin } from "./vite-plugin-auto-services.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const monorepoRoot = path.resolve(__dirname, "../.."); export default defineConfig({ + envDir: monorepoRoot, plugins: [ tsconfigPaths(), autoServicesPlugin(path.join(__dirname, "src/main/services")), diff --git a/apps/array/vite.renderer.config.mts b/apps/array/vite.renderer.config.mts index b9b84534..9cbece54 100644 --- a/apps/array/vite.renderer.config.mts +++ b/apps/array/vite.renderer.config.mts @@ -5,6 +5,7 @@ import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const _monorepoRoot = path.resolve(__dirname, "../.."); // Allow forcing dev mode in packaged builds via FORCE_DEV_MODE=1 const forceDevMode = process.env.FORCE_DEV_MODE === "1"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b77b366..96bccccb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,6 +245,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + react-error-boundary: + specifier: ^6.0.0 + version: 6.0.0(react@18.3.1) react-hook-form: specifier: ^7.64.0 version: 7.66.1(react@18.3.1) @@ -6312,6 +6315,11 @@ packages: peerDependencies: react: ^18.3.1 + react-error-boundary@6.0.0: + resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + peerDependencies: + react: '>=16.13.1' + react-hook-form@7.66.1: resolution: {integrity: sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==} engines: {node: '>=18.0.0'} @@ -14369,6 +14377,11 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-error-boundary@6.0.0(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.4 + react: 18.3.1 + react-hook-form@7.66.1(react@18.3.1): dependencies: react: 18.3.1