+ {isBrowserOnly && (
+
+
+
+ The Agent Inbox requires the Friday desktop app (Tauri). In browser mode, use the{" "}
+ Notifications panel (bell icon in the sidebar) to see agent activity from the Python backend.
+
+
+ )}
{isLoading ? (
Loading agent inbox...
) : (
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 6e63eee..bcaac29 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -9,10 +9,10 @@ import AnalyticsProvider from '@/components/AnalyticsProvider'
import { Toaster, toast } from 'sonner'
import "sonner/dist/styles.css"
import { useState, useEffect, useCallback } from 'react'
-import { listen, UnlistenFn } from '@tauri-apps/api/event'
-import { invoke } from '@tauri-apps/api/core'
+import { safeInvoke, safeListen, isTauriAvailable } from '@/lib/tauri-compat'
import { TooltipProvider } from '@/components/ui/tooltip'
import { RecordingStateProvider } from '@/contexts/RecordingStateContext'
+import { NotificationsProvider } from '@/contexts/NotificationsContext'
import { OllamaDownloadProvider } from '@/contexts/OllamaDownloadContext'
import { TranscriptProvider } from '@/contexts/TranscriptContext'
import { ConfigProvider, useConfig } from '@/contexts/ConfigContext'
@@ -78,7 +78,13 @@ export default function RootLayout({
useEffect(() => {
// Check onboarding status first
- invoke<{ completed: boolean } | null>('get_onboarding_status')
+ if (!isTauriAvailable()) {
+ console.log('[Layout] Tauri not available — skipping onboarding for browser dev')
+ setShowOnboarding(false)
+ setOnboardingCompleted(true)
+ return
+ }
+ safeInvoke<{ completed: boolean } | null>('get_onboarding_status')
.then((status) => {
const isComplete = status?.completed ?? false
setOnboardingCompleted(isComplete)
@@ -92,7 +98,6 @@ export default function RootLayout({
})
.catch((error) => {
console.error('[Layout] Failed to check onboarding status:', error)
- // Default to showing onboarding if we can't check
setShowOnboarding(true)
setOnboardingCompleted(false)
})
@@ -108,7 +113,7 @@ export default function RootLayout({
}, []);
useEffect(() => {
// Listen for tray recording toggle request
- const unlisten = listen('request-recording-toggle', () => {
+ const unlistenPromise = safeListen('request-recording-toggle', () => {
console.log('[Layout] Received request-recording-toggle from tray');
if (showOnboarding) {
@@ -123,7 +128,7 @@ export default function RootLayout({
});
return () => {
- unlisten.then(fn => fn());
+ unlistenPromise.then(fn => fn());
};
}, [showOnboarding]);
@@ -158,14 +163,14 @@ export default function RootLayout({
// Listen for drag-drop events
useEffect(() => {
- if (showOnboarding) return; // Don't handle drops during onboarding
+ if (showOnboarding || !isTauriAvailable()) return; // Don't handle drops during onboarding or browser mode
- const unlisteners: UnlistenFn[] = [];
+ const unlisteners: (() => void)[] = [];
const cleanedUpRef = { current: false };
const setupListeners = async () => {
// Drag enter/over - show overlay only if beta feature is enabled
- const unlistenDragEnter = await listen('tauri://drag-enter', () => {
+ const unlistenDragEnter = await safeListen('tauri://drag-enter', () => {
if (loadBetaFeatures().importAndRetranscribe) {
setShowDropOverlay(true);
}
@@ -177,7 +182,7 @@ export default function RootLayout({
unlisteners.push(unlistenDragEnter);
// Drag leave - hide overlay
- const unlistenDragLeave = await listen('tauri://drag-leave', () => {
+ const unlistenDragLeave = await safeListen('tauri://drag-leave', () => {
setShowDropOverlay(false);
});
if (cleanedUpRef.current) {
@@ -188,7 +193,7 @@ export default function RootLayout({
unlisteners.push(unlistenDragLeave);
// Drop - process files
- const unlistenDrop = await listen<{ paths: string[] }>('tauri://drag-drop', (event) => {
+ const unlistenDrop = await safeListen<{ paths: string[] }>('tauri://drag-drop', (event) => {
setShowDropOverlay(false);
handleFileDrop(event.payload.paths);
});
@@ -241,31 +246,33 @@ export default function RootLayout({
-
-
-
- {/* Download progress toast provider - listens for background downloads */}
-
-
- {/* Show onboarding or main app */}
- {showOnboarding ? (
-
- ) : (
-
-
- {children}
-
- )}
- {/* Import audio overlay and dialog */}
-
-
-
-
-
+
+
+
+
+ {/* Download progress toast provider - listens for background downloads */}
+
+
+ {/* Show onboarding or main app */}
+ {showOnboarding ? (
+
+ ) : (
+
+
+ {children}
+
+ )}
+ {/* Import audio overlay and dialog */}
+
+
+
+
+
+
diff --git a/frontend/src/components/AnalyticsProvider.tsx b/frontend/src/components/AnalyticsProvider.tsx
index dc1b75f..a1f7455 100644
--- a/frontend/src/components/AnalyticsProvider.tsx
+++ b/frontend/src/components/AnalyticsProvider.tsx
@@ -2,7 +2,7 @@
import React, { useEffect, ReactNode, useRef, useState, createContext } from 'react';
import Analytics from '@/lib/analytics';
-import { load } from '@tauri-apps/plugin-store';
+import { isTauriAvailable } from '@/lib/tauri-compat';
interface AnalyticsProviderProps {
@@ -30,6 +30,13 @@ export default function AnalyticsProvider({ children }: AnalyticsProviderProps)
}
const initAnalytics = async () => {
+ if (!isTauriAvailable()) {
+ // In browser mode, skip analytics entirely
+ console.log('[Analytics] Tauri not available — skipping analytics init');
+ initialized.current = true;
+ return;
+ }
+ const { load } = await import('@tauri-apps/plugin-store');
const store = await load('analytics.json', {
autoSave: false,
defaults: {
@@ -63,6 +70,7 @@ export default function AnalyticsProvider({ children }: AnalyticsProviderProps)
const deviceInfo = await Analytics.getDeviceInfo();
// Store platform info in analytics.json for quick access
+ const { load } = await import('@tauri-apps/plugin-store');
const store = await load('analytics.json', {
autoSave: false,
defaults: {
diff --git a/frontend/src/components/Notifications/NotificationCard.tsx b/frontend/src/components/Notifications/NotificationCard.tsx
new file mode 100644
index 0000000..74e771e
--- /dev/null
+++ b/frontend/src/components/Notifications/NotificationCard.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import React, { useState } from 'react';
+import { CheckCircle2, MessageCircleQuestion, Info, AlertCircle, X, Send } from 'lucide-react';
+import { useNotifications } from '@/contexts/NotificationsContext';
+
+interface Notification {
+ id: string;
+ type: 'info' | 'action_taken' | 'question' | 'error';
+ title: string;
+ message: string;
+ status: string;
+ created_at: string;
+}
+
+function timeAgo(dateStr: string): string {
+ const now = Date.now();
+ const then = new Date(dateStr).getTime();
+ const diffSec = Math.floor((now - then) / 1000);
+ if (diffSec < 60) return 'just now';
+ if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
+ if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
+ return `${Math.floor(diffSec / 86400)}d ago`;
+}
+
+const ICON_MAP = {
+ action_taken:
,
+ question:
,
+ info:
,
+ error:
,
+};
+
+export default function NotificationCard({ notification }: { notification: Notification }) {
+ const { replyToNotification, dismissNotification } = useNotifications();
+ const [replyText, setReplyText] = useState('');
+ const [sending, setSending] = useState(false);
+ const isUnread = notification.status === 'unread';
+ const isQuestion = notification.type === 'question' && notification.status !== 'answered';
+
+ const handleReply = async () => {
+ if (!replyText.trim()) return;
+ setSending(true);
+ await replyToNotification(notification.id, replyText.trim());
+ setReplyText('');
+ setSending(false);
+ };
+
+ return (
+
+
+ {ICON_MAP[notification.type] || ICON_MAP.info}
+
+
+ {notification.title}
+ {isUnread && }
+
+
{notification.message}
+
{timeAgo(notification.created_at)}
+
+ {!isQuestion && (
+
+ )}
+
+
+ {isQuestion && (
+
+ setReplyText(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleReply()}
+ placeholder="Type your reply..."
+ className="flex-1 px-2 py-1.5 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-blue-500"
+ disabled={sending}
+ />
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/Notifications/NotificationsPanel.tsx b/frontend/src/components/Notifications/NotificationsPanel.tsx
new file mode 100644
index 0000000..58115d8
--- /dev/null
+++ b/frontend/src/components/Notifications/NotificationsPanel.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import React from 'react';
+import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { useNotifications } from '@/contexts/NotificationsContext';
+import NotificationCard from './NotificationCard';
+
+export default function NotificationsPanel() {
+ const { notifications, isOpen, setIsOpen, markAllRead } = useNotifications();
+
+ return (
+
+
+
+
+ Agent Activity
+
+
+
+
+
+ {notifications.length === 0 ? (
+
+ No notifications yet. The agent will post updates here as it processes meetings.
+
+ ) : (
+ notifications.map((n) =>
)
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Sidebar/SidebarProvider.tsx b/frontend/src/components/Sidebar/SidebarProvider.tsx
index 243ed21..40142fb 100644
--- a/frontend/src/components/Sidebar/SidebarProvider.tsx
+++ b/frontend/src/components/Sidebar/SidebarProvider.tsx
@@ -3,7 +3,7 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import Analytics from '@/lib/analytics';
-import { invoke } from '@tauri-apps/api/core';
+import { safeInvoke } from '@/lib/tauri-compat';
import { useRecordingState } from '@/contexts/RecordingStateContext';
@@ -86,7 +86,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
const fetchMeetings = React.useCallback(async () => {
if (serverAddress) {
try {
- const meetings = await invoke('api_get_meetings') as Array<{ id: string, title: string }>;
+ const meetings = await safeInvoke('api_get_meetings') as Array<{ id: string, title: string }>;
const transformedMeetings = meetings.map((meeting: any) => ({
id: meeting.id,
title: meeting.title
@@ -174,7 +174,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
setIsSearching(true);
- const results = await invoke('api_search_transcripts', { query }) as TranscriptSearchResult[];
+ const results = await safeInvoke('api_search_transcripts', { query }) as TranscriptSearchResult[];
setSearchResults(results);
} catch (error) {
console.error('Error searching transcripts:', error);
@@ -219,7 +219,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
return;
}
try {
- const result = await invoke('api_get_summary', {
+ const result = await safeInvoke('api_get_summary', {
meetingId: meetingId,
}) as any;
diff --git a/frontend/src/components/Sidebar/index.tsx b/frontend/src/components/Sidebar/index.tsx
index 9294e51..6fdd305 100644
--- a/frontend/src/components/Sidebar/index.tsx
+++ b/frontend/src/components/Sidebar/index.tsx
@@ -1,7 +1,7 @@
'use client';
import React, { useState, useMemo, useEffect, useCallback } from 'react';
-import { ChevronDown, ChevronRight, File, Settings, ChevronLeftCircle, ChevronRightCircle, Calendar, StickyNote, Home, Trash2, Mic, Square, Plus, Search, Pencil, NotebookPen, SearchIcon, X, Upload, BrainCircuit } from 'lucide-react';
+import { ChevronDown, ChevronRight, File, Settings, ChevronLeftCircle, ChevronRightCircle, Calendar, StickyNote, Home, Trash2, Mic, Square, Plus, Search, Pencil, NotebookPen, SearchIcon, X, Upload, Bell, BrainCircuit } from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation';
import { useSidebar } from './SidebarProvider';
import type { CurrentMeeting } from '@/components/Sidebar/SidebarProvider';
@@ -10,12 +10,14 @@ import { ModelConfig } from '@/components/ModelSettingsModal';
import { SettingTabs } from '../SettingTabs';
import { TranscriptModelProps } from '@/components/TranscriptSettings';
import Analytics from '@/lib/analytics';
-import { invoke } from '@tauri-apps/api/core';
+import { safeInvoke, safeListen, isTauriAvailable } from '@/lib/tauri-compat';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { useRecordingState } from '@/contexts/RecordingStateContext';
import { useImportDialog } from '@/contexts/ImportDialogContext';
import { useConfig } from '@/contexts/ConfigContext';
+import { useNotifications } from '@/contexts/NotificationsContext';
+import NotificationsPanel from '@/components/Notifications/NotificationsPanel';
import {
Dialog,
@@ -61,6 +63,7 @@ const Sidebar: React.FC = () => {
const { isRecording } = useRecordingState();
const { openImportDialog } = useImportDialog();
const { betaFeatures } = useConfig();
+ const { unreadCount, setIsOpen: setNotificationsOpen } = useNotifications();
const [expandedFolders, setExpandedFolders] = useState
>(new Set(['meetings']));
const [searchQuery, setSearchQuery] = useState('');
const [showModelSettings, setShowModelSettings] = useState(false);
@@ -115,12 +118,12 @@ const Sidebar: React.FC = () => {
}
try {
- const data = await invoke('api_get_model_config') as any;
+ const data = await safeInvoke('api_get_model_config') as any;
if (data && data.provider !== null) {
// Fetch API key if not included and provider requires it
if (data.provider !== 'ollama' && !data.apiKey) {
try {
- const apiKeyData = await invoke('api_get_api_key', {
+ const apiKeyData = await safeInvoke('api_get_api_key', {
provider: data.provider
}) as string;
data.apiKey = apiKeyData;
@@ -149,7 +152,7 @@ const Sidebar: React.FC = () => {
}
try {
- const data = await invoke('api_get_transcript_config') as any;
+ const data = await safeInvoke('api_get_transcript_config') as any;
if (data && data.provider !== null) {
setTranscriptModelConfig(data);
}
@@ -163,8 +166,7 @@ const Sidebar: React.FC = () => {
// Listen for model config updates from other components
useEffect(() => {
const setupListener = async () => {
- const { listen } = await import('@tauri-apps/api/event');
- const unlisten = await listen('model-config-updated', (event) => {
+ const unlisten = await safeListen('model-config-updated', (event) => {
console.log('Sidebar received model-config-updated event:', event.payload);
setModelConfig(event.payload);
});
@@ -185,7 +187,7 @@ const Sidebar: React.FC = () => {
// Handle model config save
const handleSaveModelConfig = async (config: ModelConfig) => {
try {
- await invoke('api_save_model_config', {
+ await safeInvoke('api_save_model_config', {
provider: config.provider,
model: config.model,
whisperModel: config.whisperModel,
@@ -198,8 +200,10 @@ const Sidebar: React.FC = () => {
setSettingsSaveSuccess(true);
// Emit event to sync other components
- const { emit } = await import('@tauri-apps/api/event');
- await emit('model-config-updated', config);
+ if (isTauriAvailable()) {
+ const { emit } = await import('@tauri-apps/api/event');
+ await emit('model-config-updated', config);
+ }
// Track settings change
await Analytics.trackSettingsChanged('model_config', `${config.provider}_${config.model}`);
@@ -219,7 +223,7 @@ const Sidebar: React.FC = () => {
};
console.log('Saving transcript config with payload:', payload);
- await invoke('api_save_transcript_config', {
+ await safeInvoke('api_save_transcript_config', {
provider: payload.provider,
model: payload.model,
apiKey: payload.apiKey,
@@ -325,8 +329,7 @@ const Sidebar: React.FC = () => {
};
try {
- const { invoke } = await import('@tauri-apps/api/core');
- await invoke('api_delete_meeting', {
+ await safeInvoke('api_delete_meeting', {
meetingId: itemId,
});
console.log('Meeting deleted successfully');
@@ -384,7 +387,7 @@ const Sidebar: React.FC = () => {
}
try {
- await invoke('api_save_meeting_title', {
+ await safeInvoke('api_save_meeting_title', {
meetingId: meetingId,
title: newTitle,
});
@@ -835,6 +838,19 @@ const Sidebar: React.FC = () => {
Agent Inbox
+
+
);
};
diff --git a/frontend/src/contexts/ConfigContext.tsx b/frontend/src/contexts/ConfigContext.tsx
index 6aa6011..e38139e 100644
--- a/frontend/src/contexts/ConfigContext.tsx
+++ b/frontend/src/contexts/ConfigContext.tsx
@@ -4,7 +4,7 @@ import React, { createContext, useContext, useState, useEffect, useCallback, use
import { TranscriptModelProps } from '@/components/TranscriptSettings';
import { SelectedDevices } from '@/components/DeviceSelection';
import { configService, ModelConfig } from '@/services/configService';
-import { invoke } from '@tauri-apps/api/core';
+import { safeInvoke, safeListen, isTauriAvailable } from '@/lib/tauri-compat';
import Analytics from '@/lib/analytics';
import { BetaFeatures, BetaFeatureKey, loadBetaFeatures, saveBetaFeatures } from '@/types/betaFeatures';
@@ -180,7 +180,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const loadModels = async () => {
try {
const endpoint = modelConfig.ollamaEndpoint || null;
- const modelList = await invoke