chore: notifs on no speech detection

This commit is contained in:
haritabh-z01 2026-01-08 15:01:39 +05:30
parent 3b2bc12920
commit 6357709edf
11 changed files with 396 additions and 9 deletions

View file

@ -104,7 +104,15 @@ export class WindowManager {
logger.main.info("Theme listener setup complete");
}
async createOrShowMainWindow(): Promise<void> {
/**
* 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<void> {
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,
);
}

View file

@ -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);

View file

@ -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 <RouterProvider router={router} />;
};

View file

@ -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<NodeJS.Timeout | null>(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 (
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
pointerEvents: "auto",
zIndex: 9999,
}}
>
<Toaster position="bottom-center" />
</div>
);
};

View file

@ -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<WidgetToastProps> = ({
title,
description,
primaryAction,
secondaryAction,
onActionClick,
}) => {
return (
<Card className="min-w-[300px] gap-3 py-4 shadow-lg">
<CardHeader className="gap-1 px-4 py-0">
<CardTitle className="text-sm">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
</CardHeader>
<CardFooter className="gap-2 px-4 py-0">
{secondaryAction && (
<Button
variant="outline"
size="sm"
onClick={() => onActionClick(secondaryAction)}
>
{secondaryAction.icon === "discord" && (
<img
src="assets/discord-icon.svg"
alt="Discord"
className="size-3.5"
/>
)}
{secondaryAction.label}
</Button>
)}
{primaryAction && (
<Button
variant="default"
size="sm"
className="flex-1"
onClick={() => onActionClick(primaryAction)}
>
{primaryAction.label}
</Button>
)}
</CardFooter>
</Card>
);
};

View file

@ -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) => (
<WidgetToast
title={notification.title}
description={description}
primaryAction={notification.primaryAction}
secondaryAction={notification.secondaryAction}
onActionClick={(action) => {
handleActionClick(action);
toast.dismiss(toastId);
}}
/>
),
{
unstyled: true,
duration: WIDGET_NOTIFICATION_TIMEOUT,
onDismiss: reEnablePassThrough,
onAutoClose: reEnablePassThrough,
},
);
},
onError: (error) => {
console.error("Widget notification subscription error:", error);
},
});
};

View file

@ -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(
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<WidgetPage />
</QueryClientProvider>
</api.Provider>,
<ThemeProvider>
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<WidgetPage />
<ToasterWrapper />
</QueryClientProvider>
</api.Provider>
</ThemeProvider>,
);
} else {
console.error(

View file

@ -1,5 +1,7 @@
import { FloatingButton } from "./components/FloatingButton";
import { useWidgetNotifications } from "../../hooks/useWidgetNotifications";
export function WidgetPage() {
useWidgetNotifications();
return <FloatingButton />;
}

View file

@ -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<WidgetNotification>((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);
};
});
}),
});

View file

@ -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;
}),
});

View file

@ -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;