chore: notifs on no speech detection
This commit is contained in:
parent
3b2bc12920
commit
6357709edf
11 changed files with 396 additions and 9 deletions
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
64
apps/desktop/src/renderer/widget/components/WidgetToast.tsx
Normal file
64
apps/desktop/src/renderer/widget/components/WidgetToast.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { FloatingButton } from "./components/FloatingButton";
|
||||
import { useWidgetNotifications } from "../../hooks/useWidgetNotifications";
|
||||
|
||||
export function WidgetPage() {
|
||||
useWidgetNotifications();
|
||||
return <FloatingButton />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
77
apps/desktop/src/types/widget-notification.ts
Normal file
77
apps/desktop/src/types/widget-notification.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue