diff --git a/.env.example b/.env.example index 242ec98d..e9766842 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,7 @@ VITE_POSTHOG_API_HOST=xxx VITE_POSTHOG_UI_HOST=xxx # Use new LLM gateway locally (experimental, needs to be started in mprocs) -LLM_GATEWAY_URL=http://localhost:3308 \ No newline at end of file +LLM_GATEWAY_URL=http://localhost:3308 + +# Whether all errors/warnings show as dismissable toasts in dev +VITE_DEV_ERROR_TOASTS=true \ No newline at end of file diff --git a/apps/array/package.json b/apps/array/package.json index 26482033..674c0534 100644 --- a/apps/array/package.json +++ b/apps/array/package.json @@ -137,6 +137,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 47a1e50d..3bc132fb 100644 --- a/apps/array/src/main/index.ts +++ b/apps/array/src/main/index.ts @@ -7,6 +7,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 { app, BrowserWindow, @@ -17,7 +21,6 @@ import { shell, } from "electron"; import { createIPCHandler } from "trpc-electron/main"; -import "./lib/logger"; import { ANALYTICS_EVENTS } from "../types/analytics.js"; import { container } from "./di/container.js"; import { MAIN_TOKENS } from "./di/tokens.js"; 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 1da85659..97592633 100644 --- a/apps/array/src/main/preload.ts +++ b/apps/array/src/main/preload.ts @@ -1,6 +1,23 @@ +import { ipcRenderer } from "electron"; import { exposeElectronTRPC } from "trpc-electron/main"; 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 10a1d6d9..f9dd6977 100644 --- a/apps/array/src/main/trpc/router.ts +++ b/apps/array/src/main/trpc/router.ts @@ -15,6 +15,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"; @@ -35,6 +36,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 cd9e931c..f6d5174a 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 107eaa1f..f694674a 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 998349c7..9dfa2163 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(); + const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("Root element not found"); diff --git a/apps/array/src/renderer/sagas/task/task-creation.ts b/apps/array/src/renderer/sagas/task/task-creation.ts index 6ed1dfb6..c45730f3 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..08f75469 100644 --- a/apps/array/src/renderer/utils/toast.tsx +++ b/apps/array/src/renderer/utils/toast.tsx @@ -1,5 +1,11 @@ -import { CheckIcon, InfoIcon, WarningIcon, XIcon } from "@phosphor-icons/react"; -import { Card, Flex, Spinner, Text } from "@radix-ui/themes"; +import { + CheckIcon, + InfoIcon, + WarningIcon, + X, + XIcon, +} from "@phosphor-icons/react"; +import { Card, Flex, IconButton, Spinner, Text } from "@radix-ui/themes"; import { toast as sonnerToast } from "sonner"; interface ToastProps { @@ -7,10 +13,11 @@ interface ToastProps { type: "loading" | "success" | "error" | "info" | "warning"; title: string; description?: string; + dismissable?: boolean; } function ToastComponent(props: ToastProps) { - const { type, title, description } = props; + const { id, type, title, description, dismissable = false } = props; const getIcon = () => { switch (type) { @@ -48,6 +55,17 @@ function ToastComponent(props: ToastProps) { )} + {dismissable && ( + sonnerToast.dismiss(id)} + style={{ flexShrink: 0, marginTop: "-2px", marginRight: "-4px" }} + > + + + )} ); @@ -76,6 +94,7 @@ export const toast = { type="success" title={title} description={options?.description} + dismissable /> ), { id: options?.id }, @@ -93,6 +112,7 @@ export const toast = { type="error" title={title} description={options?.description} + dismissable /> ), { id: options?.id }, @@ -106,6 +126,7 @@ export const toast = { type="info" title={title} description={description} + dismissable /> )); }, @@ -121,6 +142,7 @@ export const toast = { type="warning" title={title} description={options?.description} + dismissable /> ), { id: options?.id, duration: options?.duration }, 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..e0055b1c --- /dev/null +++ b/apps/array/src/shared/utils/format.ts @@ -0,0 +1,26 @@ +/** + * 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; +} + +export function formatArgsToString(args: unknown[], maxLength = 200): string { + return args + .map((a) => (a instanceof Error ? a.message : String(a))) + .join(" ") + .slice(0, maxLength); +} 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 475af875..d9866a58 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. @@ -155,6 +156,7 @@ function copyClaudeExecutable(): Plugin { const forceDevMode = process.env.FORCE_DEV_MODE === "1"; export default defineConfig({ + envDir: monorepoRoot, plugins: [ tsconfigPaths(), autoServicesPlugin(join(__dirname, "src/main/services")), 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 ce56c003..161638d6 100644 --- a/apps/array/vite.renderer.config.mts +++ b/apps/array/vite.renderer.config.mts @@ -5,11 +5,13 @@ 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"; export default defineConfig({ + envDir: monorepoRoot, plugins: [react(), tsconfigPaths()], define: forceDevMode ? { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6d6fbdd..af1b13ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,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) @@ -5928,6 +5931,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'} @@ -13416,6 +13424,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