diff --git a/apps/desktop/src/main/core/window-manager.ts b/apps/desktop/src/main/core/window-manager.ts index e5412af..8fd8b15 100644 --- a/apps/desktop/src/main/core/window-manager.ts +++ b/apps/desktop/src/main/core/window-manager.ts @@ -104,7 +104,15 @@ export class WindowManager { logger.main.info("Theme listener setup complete"); } - async createOrShowMainWindow(): Promise { + /** + * Creates a new main window or shows existing one. + * @param initialRoute - Optional route to navigate to when creating a NEW window. + * This is passed as a URL hash to avoid race conditions where + * the renderer isn't ready to receive IPC navigation events. + * If window already exists, caller should use webContents.send() + * to navigate (renderer is already loaded and listening). + */ + async createOrShowMainWindow(initialRoute?: string): Promise { if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.show(); this.mainWindow.focus(); @@ -139,11 +147,17 @@ export class WindowManager { }, }); + // Load the window URL, appending initial route as hash if provided + // This avoids race conditions when the renderer isn't ready for IPC events if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { - this.mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); + const url = initialRoute + ? `${MAIN_WINDOW_VITE_DEV_SERVER_URL}#${initialRoute}` + : MAIN_WINDOW_VITE_DEV_SERVER_URL; + this.mainWindow.loadURL(url); } else { this.mainWindow.loadFile( path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), + initialRoute ? { hash: initialRoute } : undefined, ); } diff --git a/apps/desktop/src/main/managers/recording-manager.ts b/apps/desktop/src/main/managers/recording-manager.ts index 0ad13d3..04bff82 100644 --- a/apps/desktop/src/main/managers/recording-manager.ts +++ b/apps/desktop/src/main/managers/recording-manager.ts @@ -546,6 +546,18 @@ export class RecordingManager extends EventEmitter { if (result) { await this.pasteTranscription(result); + } else { + // Check for empty transcript notification + const sessionDurationMs = + this.recordingStoppedAt && this.recordingStartedAt + ? this.recordingStoppedAt - this.recordingStartedAt + : 0; + if (sessionDurationMs > 5000) { + this.emit("widget-notification", { type: "empty_transcript" }); + logger.audio.info("Emitted widget notification", { + type: "empty_transcript", + }); + } } this.resetSessionState(); @@ -603,6 +615,8 @@ export class RecordingManager extends EventEmitter { if (this.recordingState === "recording" && !this.firstChunkReceived) { logger.audio.warn("No audio detected for 5 seconds"); this.emit("no-audio-detected"); + this.emit("widget-notification", { type: "no_audio" }); + logger.audio.info("Emitted widget notification", { type: "no_audio" }); this.endRecording("no_audio"); } }, NO_AUDIO_TIMEOUT); diff --git a/apps/desktop/src/renderer/main/content.tsx b/apps/desktop/src/renderer/main/content.tsx index b6a993f..8294595 100644 --- a/apps/desktop/src/renderer/main/content.tsx +++ b/apps/desktop/src/renderer/main/content.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import { RouterProvider, createRouter, @@ -24,6 +24,19 @@ declare module "@tanstack/react-router" { // Root App component with routing const App: React.FC = () => { + // Listen for navigation events from main process (e.g., from widget) + useEffect(() => { + const handleNavigate = (route: string) => { + router.navigate({ to: route }); + }; + + window.electronAPI?.on?.("navigate", handleNavigate); + + return () => { + window.electronAPI?.off?.("navigate", handleNavigate); + }; + }, []); + return ; }; diff --git a/apps/desktop/src/renderer/widget/components/ToasterWrapper.tsx b/apps/desktop/src/renderer/widget/components/ToasterWrapper.tsx new file mode 100644 index 0000000..db52101 --- /dev/null +++ b/apps/desktop/src/renderer/widget/components/ToasterWrapper.tsx @@ -0,0 +1,54 @@ +import React, { useRef } from "react"; +import { Toaster } from "@/components/ui/sonner"; +import { api } from "@/trpc/react"; + +const DEBOUNCE_DELAY = 100; + +/** + * Wrapper for Toaster that handles mouse events to enable/disable + * pass-through on the widget window, making toasts clickable. + */ +export const ToasterWrapper: React.FC = () => { + const setIgnoreMouseEvents = api.widget.setIgnoreMouseEvents.useMutation(); + const leaveTimeoutRef = useRef(null); + + const handleMouseEnter = async () => { + console.log("ToasterWrapper: mouse enter"); + if (leaveTimeoutRef.current) { + clearTimeout(leaveTimeoutRef.current); + leaveTimeoutRef.current = null; + } + // Disable pass-through to make toast clickable + await setIgnoreMouseEvents.mutateAsync({ ignore: false }); + console.log("ToasterWrapper: pass-through disabled"); + }; + + const handleMouseLeave = () => { + console.log("ToasterWrapper: mouse leave"); + if (leaveTimeoutRef.current) { + clearTimeout(leaveTimeoutRef.current); + } + leaveTimeoutRef.current = setTimeout(async () => { + // Re-enable pass-through + await setIgnoreMouseEvents.mutateAsync({ ignore: true }); + console.log("ToasterWrapper: pass-through re-enabled"); + }, DEBOUNCE_DELAY); + }; + + return ( +
+ +
+ ); +}; diff --git a/apps/desktop/src/renderer/widget/components/WidgetToast.tsx b/apps/desktop/src/renderer/widget/components/WidgetToast.tsx new file mode 100644 index 0000000..159a0aa --- /dev/null +++ b/apps/desktop/src/renderer/widget/components/WidgetToast.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import type { WidgetNotificationAction } from "@/types/widget-notification"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface WidgetToastProps { + title: string; + description: string; + primaryAction?: WidgetNotificationAction; + secondaryAction?: WidgetNotificationAction; + onActionClick: (action: WidgetNotificationAction) => void; +} + +export const WidgetToast: React.FC = ({ + title, + description, + primaryAction, + secondaryAction, + onActionClick, +}) => { + return ( + + + {title} + {description} + + + + {secondaryAction && ( + + )} + {primaryAction && ( + + )} + + + ); +}; diff --git a/apps/desktop/src/renderer/widget/hooks/useWidgetNotifications.tsx b/apps/desktop/src/renderer/widget/hooks/useWidgetNotifications.tsx new file mode 100644 index 0000000..e2541d0 --- /dev/null +++ b/apps/desktop/src/renderer/widget/hooks/useWidgetNotifications.tsx @@ -0,0 +1,70 @@ +import { toast } from "sonner"; +import { api } from "@/trpc/react"; +import { useAudioDevices } from "@/hooks/useAudioDevices"; +import { + WIDGET_NOTIFICATION_TIMEOUT, + getNotificationDescription, + type WidgetNotificationAction, +} from "@/types/widget-notification"; +import { WidgetToast } from "../components/WidgetToast"; + +export const useWidgetNotifications = () => { + const navigateMainWindow = api.widget.navigateMainWindow.useMutation(); + const setIgnoreMouseEvents = api.widget.setIgnoreMouseEvents.useMutation(); + const { data: settings } = api.settings.getSettings.useQuery(); + const { defaultDeviceName } = useAudioDevices(); + + // Get effective mic name: preferred from settings, or system default + const getEffectiveMicName = () => { + return settings?.recording?.preferredMicrophoneName || defaultDeviceName; + }; + + const reEnablePassThrough = () => { + setTimeout(() => { + setIgnoreMouseEvents.mutate({ ignore: true }); + }, 100); + }; + + const handleActionClick = async (action: WidgetNotificationAction) => { + if (action.navigateTo) { + navigateMainWindow.mutate({ route: action.navigateTo }); + } else if (action.externalUrl) { + await window.electronAPI.openExternal(action.externalUrl); + } + reEnablePassThrough(); + }; + + api.recording.widgetNotifications.useSubscription(undefined, { + onData: (notification) => { + const micName = getEffectiveMicName(); + const description = getNotificationDescription( + notification.type, + micName, + ); + + toast.custom( + (toastId) => ( + { + handleActionClick(action); + toast.dismiss(toastId); + }} + /> + ), + { + unstyled: true, + duration: WIDGET_NOTIFICATION_TIMEOUT, + onDismiss: reEnablePassThrough, + onAutoClose: reEnablePassThrough, + }, + ); + }, + onError: (error) => { + console.error("Widget notification subscription error:", error); + }, + }); +}; diff --git a/apps/desktop/src/renderer/widget/index.tsx b/apps/desktop/src/renderer/widget/index.tsx index 4cbb156..5f3f8fb 100644 --- a/apps/desktop/src/renderer/widget/index.tsx +++ b/apps/desktop/src/renderer/widget/index.tsx @@ -3,6 +3,8 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { WidgetPage } from "./pages/widget"; import { api, trpcClient } from "@/trpc/react"; +import { ThemeProvider } from "@/components/theme-provider"; +import { ToasterWrapper } from "./components/ToasterWrapper"; import "@/styles/globals.css"; // Extend Console interface to include original methods @@ -69,11 +71,14 @@ const container = document.getElementById("root"); if (container) { const root = createRoot(container); root.render( - - - - - , + + + + + + + + , ); } else { console.error( diff --git a/apps/desktop/src/renderer/widget/pages/widget/index.tsx b/apps/desktop/src/renderer/widget/pages/widget/index.tsx index d808a49..aef33fb 100644 --- a/apps/desktop/src/renderer/widget/pages/widget/index.tsx +++ b/apps/desktop/src/renderer/widget/pages/widget/index.tsx @@ -1,5 +1,7 @@ import { FloatingButton } from "./components/FloatingButton"; +import { useWidgetNotifications } from "../../hooks/useWidgetNotifications"; export function WidgetPage() { + useWidgetNotifications(); return ; } diff --git a/apps/desktop/src/trpc/routers/recording.ts b/apps/desktop/src/trpc/routers/recording.ts index 5b077ba..17b528e 100644 --- a/apps/desktop/src/trpc/routers/recording.ts +++ b/apps/desktop/src/trpc/routers/recording.ts @@ -1,8 +1,13 @@ import { observable } from "@trpc/server/observable"; import { createRouter, procedure } from "../trpc"; -import { z } from "zod"; +import { v4 as uuid } from "uuid"; import type { RecordingState } from "../../types/recording"; import type { RecordingMode } from "../../main/managers/recording-manager"; +import type { + WidgetNotification, + WidgetNotificationType, +} from "../../types/widget-notification"; +import { WIDGET_NOTIFICATION_CONFIG } from "../../types/widget-notification"; interface RecordingStateUpdate { state: RecordingState; @@ -103,4 +108,35 @@ export const recordingRouter = createRouter({ }; }); }), + + // Widget notification subscription + widgetNotifications: procedure.subscription(({ ctx }) => { + return observable((emit) => { + const recordingManager = + ctx.serviceManager.getService("recordingManager"); + if (!recordingManager) { + throw new Error("Recording manager not available"); + } + + const handleNotification = (data: { type: WidgetNotificationType }) => { + const config = WIDGET_NOTIFICATION_CONFIG[data.type]; + emit.next({ + id: uuid(), + type: data.type, + title: config.title, + // Description will be hydrated on frontend with mic name + primaryAction: config.primaryAction, + secondaryAction: config.secondaryAction, + timestamp: Date.now(), + }); + }; + + recordingManager.on("widget-notification", handleNotification); + + // Cleanup function + return () => { + recordingManager.off("widget-notification", handleNotification); + }; + }); + }), }); diff --git a/apps/desktop/src/trpc/routers/widget.ts b/apps/desktop/src/trpc/routers/widget.ts index e41de20..cb87497 100644 --- a/apps/desktop/src/trpc/routers/widget.ts +++ b/apps/desktop/src/trpc/routers/widget.ts @@ -23,4 +23,42 @@ export const widgetRouter = createRouter({ logger.main.debug("Set widget ignore mouse events", input); return true; }), + + // Navigate to a route in the main window (show and focus it first) + navigateMainWindow: procedure + .input( + z.object({ + route: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const windowManager = ctx.serviceManager.getService("windowManager"); + if (!windowManager) { + logger.main.error("Window manager service not available"); + return false; + } + + // Check if window already exists before creating + const windowExisted = windowManager.getMainWindow() !== null; + + // Create or show main window, passing route for new window case + // If window is being created fresh, the route is baked into the URL hash + // to avoid race condition where renderer isn't ready for IPC events + await windowManager.createOrShowMainWindow(input.route); + + // If window already existed, send navigation event via IPC + // (renderer is already loaded and listening) + if (windowExisted) { + const mainWindow = windowManager.getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("navigate", input.route); + } + } + + logger.main.info("Navigated main window", { + route: input.route, + windowExisted, + }); + return true; + }), }); diff --git a/apps/desktop/src/types/widget-notification.ts b/apps/desktop/src/types/widget-notification.ts new file mode 100644 index 0000000..4834819 --- /dev/null +++ b/apps/desktop/src/types/widget-notification.ts @@ -0,0 +1,77 @@ +export type WidgetNotificationType = "no_audio" | "empty_transcript"; + +export type WidgetNotificationActionIcon = "discord"; + +export interface WidgetNotificationAction { + label: string; + icon?: WidgetNotificationActionIcon; + navigateTo?: string; // Route to navigate to in main window + externalUrl?: string; // External URL to open +} + +export interface WidgetNotificationConfig { + title: string; + description: string; + primaryAction?: WidgetNotificationAction; + secondaryAction?: WidgetNotificationAction; +} + +export interface WidgetNotification { + id: string; + type: WidgetNotificationType; + title: string; + primaryAction?: WidgetNotificationAction; + secondaryAction?: WidgetNotificationAction; + timestamp: number; +} + +// Template function to generate description with mic name (used on frontend) +export const getNotificationDescription = ( + type: WidgetNotificationType, + microphoneName?: string, +): string => { + const micDisplay = microphoneName || "your microphone"; + switch (type) { + case "no_audio": + return `No audio from "${micDisplay}"`; + case "empty_transcript": + return `No speech detected from "${micDisplay}"`; + } +}; + +// Discord support server URL (same as sidebar Community link) +export const DISCORD_SUPPORT_URL = "https://amical.ai/community"; + +export const WIDGET_NOTIFICATION_CONFIG: Record< + WidgetNotificationType, + WidgetNotificationConfig +> = { + no_audio: { + title: "No audio detected", + description: "Check your microphone settings", // Fallback, replaced by template + primaryAction: { + label: "Configure Microphone", + navigateTo: "/settings/dictation", + }, + secondaryAction: { + label: "Support", + icon: "discord", + externalUrl: DISCORD_SUPPORT_URL, + }, + }, + empty_transcript: { + title: "No speech detected", + description: "Try speaking louder or closer to the mic", // Fallback, replaced by template + primaryAction: { + label: "Configure Microphone", + navigateTo: "/settings/dictation", + }, + secondaryAction: { + label: "Support", + icon: "discord", + externalUrl: DISCORD_SUPPORT_URL, + }, + }, +}; + +export const WIDGET_NOTIFICATION_TIMEOUT = 5000;