feat: implement notifications and event history features

- Added Notification Service to manage project-level notifications, including creation, listing, marking as read, and dismissing notifications.
- Introduced Event History Service to store and manage historical events, allowing for listing, retrieval, deletion, and replaying of events.
- Integrated notifications into the server and UI, providing real-time updates for feature statuses and operations.
- Enhanced sidebar and project switcher components to display unread notifications count.
- Created dedicated views for managing notifications and event history, improving user experience and accessibility.

These changes enhance the application's ability to inform users about important events and statuses, improving overall usability and responsiveness.
This commit is contained in:
webdevcody
2026-01-16 18:37:11 -05:00
parent 3bdf3cbb5c
commit bd3999416b
42 changed files with 3056 additions and 62 deletions

View File

@@ -0,0 +1,207 @@
/**
* Notification Bell - Bell icon with unread count and popover
*/
import { useCallback } from 'react';
import { Bell, Check, Trash2, ExternalLink } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { useNotificationsStore } from '@/store/notifications-store';
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
import { getHttpApiClient } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { Notification } from '@automaker/types';
import { cn } from '@/lib/utils';
/**
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
return date.toLocaleDateString();
}
interface NotificationBellProps {
projectPath: string | null;
}
export function NotificationBell({ projectPath }: NotificationBellProps) {
const navigate = useNavigate();
const {
notifications,
unreadCount,
isPopoverOpen,
setPopoverOpen,
markAsRead,
dismissNotification,
} = useNotificationsStore();
// Load notifications and subscribe to events
useLoadNotifications(projectPath);
useNotificationEvents(projectPath);
const handleMarkAsRead = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
markAsRead(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.markAsRead(projectPath, notificationId);
},
[projectPath, markAsRead]
);
const handleDismiss = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
dismissNotification(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.dismiss(projectPath, notificationId);
},
[projectPath, dismissNotification]
);
const handleNotificationClick = useCallback(
(notification: Notification) => {
// Mark as read
handleMarkAsRead(notification.id);
setPopoverOpen(false);
// Navigate to the relevant view based on notification type
if (notification.featureId) {
navigate({ to: '/board' });
}
},
[handleMarkAsRead, setPopoverOpen, navigate]
);
const handleViewAll = useCallback(() => {
setPopoverOpen(false);
navigate({ to: '/notifications' });
}, [setPopoverOpen, navigate]);
const getNotificationIcon = (type: string) => {
switch (type) {
case 'feature_waiting_approval':
return <Bell className="h-4 w-4 text-yellow-500" />;
case 'feature_verified':
return <Check className="h-4 w-4 text-green-500" />;
case 'spec_regeneration_complete':
return <Check className="h-4 w-4 text-blue-500" />;
default:
return <Bell className="h-4 w-4" />;
}
};
// Show recent 3 notifications in popover
const recentNotifications = notifications.slice(0, 3);
if (!projectPath) {
return null;
}
return (
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<button
className={cn(
'relative flex items-center justify-center w-8 h-8 rounded-md',
'hover:bg-accent transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
)}
title="Notifications"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start" side="right">
<div className="flex items-center justify-between px-4 py-3 border-b">
<h4 className="font-medium text-sm">Notifications</h4>
{unreadCount > 0 && (
<span className="text-xs text-muted-foreground">{unreadCount} unread</span>
)}
</div>
{recentNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 px-4">
<Bell className="h-8 w-8 text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No notifications</p>
</div>
) : (
<div className="max-h-[300px] overflow-y-auto">
{recentNotifications.map((notification) => (
<div
key={notification.id}
className={cn(
'flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-accent/50 border-b last:border-b-0',
!notification.read && 'bg-primary/5'
)}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex-shrink-0 mt-0.5">{getNotificationIcon(notification.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate">{notification.title}</p>
{!notification.read && (
<span className="h-1.5 w-1.5 rounded-full bg-primary flex-shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
{notification.message}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
{formatRelativeTime(new Date(notification.createdAt))}
</p>
</div>
<div className="flex-shrink-0 flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDismiss(notification.id);
}}
title="Dismiss"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
{notifications.length > 0 && (
<div className="border-t px-4 py-2">
<Button variant="ghost" size="sm" className="w-full text-xs" onClick={handleViewAll}>
View all notifications
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -7,6 +7,7 @@ import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item';
import { ProjectContextMenu } from './components/project-context-menu';
import { EditProjectDialog } from './components/edit-project-dialog';
import { NotificationBell } from './components/notification-bell';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
@@ -327,6 +328,11 @@ export function ProjectSwitcher() {
v{appVersion} {versionSuffix}
</span>
</button>
{/* Notification Bell */}
<div className="flex justify-center mt-2">
<NotificationBell projectPath={currentProject?.path ?? null} />
</div>
<div className="w-full h-px bg-border mt-3" />
</div>

View File

@@ -5,6 +5,7 @@ import { useNavigate, useLocation } from '@tanstack/react-router';
const logger = createLogger('Sidebar');
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useNotificationsStore } from '@/store/notifications-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
@@ -62,6 +63,9 @@ export function Sidebar() {
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
// Get unread notifications count
const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
@@ -238,6 +242,7 @@ export function Sidebar() {
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating: isCurrentProjectGeneratingSpec,
});

View File

@@ -11,6 +11,7 @@ import {
Lightbulb,
Brain,
Network,
Bell,
} from 'lucide-react';
import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
@@ -35,6 +36,7 @@ interface UseNavigationProps {
ideation: string;
githubIssues: string;
githubPrs: string;
notifications: string;
};
hideSpecEditor: boolean;
hideContext: boolean;
@@ -49,6 +51,8 @@ interface UseNavigationProps {
cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number;
/** Count of unread notifications to show on Notifications nav item */
unreadNotificationsCount?: number;
/** Whether spec generation is currently running for the current project */
isSpecGenerating?: boolean;
}
@@ -67,6 +71,7 @@ export function useNavigation({
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating,
}: UseNavigationProps) {
// Track if current project has a GitHub remote
@@ -199,6 +204,20 @@ export function useNavigation({
});
}
// Add Other section with notifications
sections.push({
label: 'Other',
items: [
{
id: 'notifications',
label: 'Notifications',
icon: Bell,
shortcut: shortcuts.notifications,
count: unreadNotificationsCount,
},
],
});
return sections;
}, [
shortcuts,
@@ -207,6 +226,7 @@ export function useNavigation({
hideTerminal,
hasGitHubRemote,
unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating,
]);

View File

@@ -0,0 +1,272 @@
/**
* Notifications View - Full page view for all notifications
*/
import { useEffect, useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { useNotificationsStore } from '@/store/notifications-store';
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
import { getHttpApiClient } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Bell, Check, CheckCheck, Trash2, ExternalLink, Loader2 } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import type { Notification } from '@automaker/types';
/**
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
return date.toLocaleDateString();
}
export function NotificationsView() {
const { currentProject } = useAppStore();
const projectPath = currentProject?.path ?? null;
const navigate = useNavigate();
const {
notifications,
unreadCount,
isLoading,
error,
setNotifications,
setUnreadCount,
markAsRead,
dismissNotification,
markAllAsRead,
dismissAll,
} = useNotificationsStore();
// Load notifications when project changes
useLoadNotifications(projectPath);
// Subscribe to real-time notification events
useNotificationEvents(projectPath);
const handleMarkAsRead = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
markAsRead(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.markAsRead(projectPath, notificationId);
},
[projectPath, markAsRead]
);
const handleDismiss = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
dismissNotification(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.dismiss(projectPath, notificationId);
},
[projectPath, dismissNotification]
);
const handleMarkAllAsRead = useCallback(async () => {
if (!projectPath) return;
// Optimistic update
markAllAsRead();
// Sync with server
const api = getHttpApiClient();
await api.notifications.markAsRead(projectPath);
}, [projectPath, markAllAsRead]);
const handleDismissAll = useCallback(async () => {
if (!projectPath) return;
// Optimistic update
dismissAll();
// Sync with server
const api = getHttpApiClient();
await api.notifications.dismiss(projectPath);
}, [projectPath, dismissAll]);
const handleNotificationClick = useCallback(
(notification: Notification) => {
// Mark as read
handleMarkAsRead(notification.id);
// Navigate to the relevant view based on notification type
if (notification.featureId) {
// Navigate to board view - feature will be selected
navigate({ to: '/board' });
}
},
[handleMarkAsRead, navigate]
);
const getNotificationIcon = (type: string) => {
switch (type) {
case 'feature_waiting_approval':
return <Bell className="h-5 w-5 text-yellow-500" />;
case 'feature_verified':
return <Check className="h-5 w-5 text-green-500" />;
case 'spec_regeneration_complete':
return <Check className="h-5 w-5 text-blue-500" />;
case 'agent_complete':
return <Check className="h-5 w-5 text-purple-500" />;
default:
return <Bell className="h-5 w-5" />;
}
};
if (!projectPath) {
return (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<Bell className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">Select a project to view notifications</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground mt-4">Loading notifications...</p>
</div>
);
}
if (error) {
return (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="flex flex-1 flex-col p-6 overflow-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Notifications</h1>
<p className="text-muted-foreground">
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up!'}
</p>
</div>
{notifications.length > 0 && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleMarkAllAsRead}
disabled={unreadCount === 0}
>
<CheckCheck className="h-4 w-4 mr-2" />
Mark all as read
</Button>
<Button variant="outline" size="sm" onClick={handleDismissAll}>
<Trash2 className="h-4 w-4 mr-2" />
Dismiss all
</Button>
</div>
)}
</div>
{notifications.length === 0 ? (
<Card className="flex-1">
<CardContent className="flex flex-col items-center justify-center h-full min-h-[300px]">
<Bell className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground text-lg">No notifications</p>
<p className="text-muted-foreground text-sm mt-2">
Notifications will appear here when features are ready for review or operations
complete.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{notifications.map((notification) => (
<Card
key={notification.id}
className={`transition-colors cursor-pointer hover:bg-accent/50 ${
!notification.read ? 'border-primary/50 bg-primary/5' : ''
}`}
onClick={() => handleNotificationClick(notification)}
>
<CardContent className="flex items-start gap-4 p-4">
<div className="flex-shrink-0 mt-1">{getNotificationIcon(notification.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{notification.title}</CardTitle>
{!notification.read && (
<span className="h-2 w-2 rounded-full bg-primary flex-shrink-0" />
)}
</div>
<CardDescription className="mt-1">{notification.message}</CardDescription>
<p className="text-xs text-muted-foreground mt-2">
{formatRelativeTime(new Date(notification.createdAt))}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{!notification.read && (
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleMarkAsRead(notification.id);
}}
title="Mark as read"
>
<Check className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDismiss(notification.id);
}}
title="Dismiss"
>
<Trash2 className="h-4 w-4" />
</Button>
{notification.featureId && (
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleNotificationClick(notification);
}}
title="Go to feature"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,341 @@
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
History,
RefreshCw,
Trash2,
Play,
ChevronDown,
ChevronRight,
CheckCircle,
XCircle,
Clock,
AlertCircle,
} from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import type { StoredEventSummary, StoredEvent, EventHookTrigger } from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { getHttpApiClient } from '@/lib/http-api-client';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
export function EventHistoryView() {
const currentProject = useAppStore((state) => state.currentProject);
const projectPath = currentProject?.path;
const [events, setEvents] = useState<StoredEventSummary[]>([]);
const [loading, setLoading] = useState(false);
const [expandedEvent, setExpandedEvent] = useState<string | null>(null);
const [expandedEventData, setExpandedEventData] = useState<StoredEvent | null>(null);
const [replayingEvent, setReplayingEvent] = useState<string | null>(null);
const [clearDialogOpen, setClearDialogOpen] = useState(false);
const loadEvents = useCallback(async () => {
if (!projectPath) return;
setLoading(true);
try {
const api = getHttpApiClient();
const result = await api.eventHistory.list(projectPath, { limit: 100 });
if (result.success && result.events) {
setEvents(result.events);
}
} catch (error) {
console.error('Failed to load events:', error);
} finally {
setLoading(false);
}
}, [projectPath]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
const handleExpand = async (eventId: string) => {
if (expandedEvent === eventId) {
setExpandedEvent(null);
setExpandedEventData(null);
return;
}
if (!projectPath) return;
setExpandedEvent(eventId);
try {
const api = getHttpApiClient();
const result = await api.eventHistory.get(projectPath, eventId);
if (result.success && result.event) {
setExpandedEventData(result.event);
}
} catch (error) {
console.error('Failed to load event details:', error);
}
};
const handleReplay = async (eventId: string) => {
if (!projectPath) return;
setReplayingEvent(eventId);
try {
const api = getHttpApiClient();
const result = await api.eventHistory.replay(projectPath, eventId);
if (result.success && result.result) {
const { hooksTriggered, hookResults } = result.result;
const successCount = hookResults.filter((r) => r.success).length;
const failCount = hookResults.filter((r) => !r.success).length;
if (hooksTriggered === 0) {
alert('No matching hooks found for this event trigger.');
} else if (failCount === 0) {
alert(`Successfully ran ${successCount} hook(s).`);
} else {
alert(`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`);
}
}
} catch (error) {
console.error('Failed to replay event:', error);
alert('Failed to replay event. Check console for details.');
} finally {
setReplayingEvent(null);
}
};
const handleDelete = async (eventId: string) => {
if (!projectPath) return;
try {
const api = getHttpApiClient();
const result = await api.eventHistory.delete(projectPath, eventId);
if (result.success) {
setEvents((prev) => prev.filter((e) => e.id !== eventId));
if (expandedEvent === eventId) {
setExpandedEvent(null);
setExpandedEventData(null);
}
}
} catch (error) {
console.error('Failed to delete event:', error);
}
};
const handleClearAll = async () => {
if (!projectPath) return;
try {
const api = getHttpApiClient();
const result = await api.eventHistory.clear(projectPath);
if (result.success) {
setEvents([]);
setExpandedEvent(null);
setExpandedEventData(null);
}
} catch (error) {
console.error('Failed to clear events:', error);
}
setClearDialogOpen(false);
};
const getTriggerIcon = (trigger: EventHookTrigger) => {
switch (trigger) {
case 'feature_created':
return <Clock className="w-4 h-4 text-blue-500" />;
case 'feature_success':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'feature_error':
return <XCircle className="w-4 h-4 text-red-500" />;
case 'auto_mode_complete':
return <CheckCircle className="w-4 h-4 text-purple-500" />;
case 'auto_mode_error':
return <AlertCircle className="w-4 h-4 text-orange-500" />;
default:
return <History className="w-4 h-4 text-muted-foreground" />;
}
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
if (!projectPath) {
return (
<div className="text-center py-8 text-muted-foreground">
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">Select a project to view event history</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header with actions */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{events.length} event{events.length !== 1 ? 's' : ''} recorded
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadEvents} disabled={loading}>
<RefreshCw className={cn('w-4 h-4 mr-2', loading && 'animate-spin')} />
Refresh
</Button>
{events.length > 0 && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setClearDialogOpen(true)}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear All
</Button>
)}
</div>
</div>
{/* Events list */}
{events.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No events recorded yet</p>
<p className="text-xs mt-1">
Events will appear here when features are created or completed
</p>
</div>
) : (
<div className="space-y-2">
{events.map((event) => (
<div
key={event.id}
className={cn(
'rounded-lg border bg-background/50',
expandedEvent === event.id && 'ring-1 ring-brand-500/30'
)}
>
{/* Event header */}
<div
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/30 transition-colors"
onClick={() => handleExpand(event.id)}
>
<button className="p-0.5">
{expandedEvent === event.id ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</button>
{getTriggerIcon(event.trigger)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{EVENT_HOOK_TRIGGER_LABELS[event.trigger]}
</p>
{event.featureName && (
<p className="text-xs text-muted-foreground truncate">{event.featureName}</p>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatTimestamp(event.timestamp)}
</span>
{/* Actions */}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleReplay(event.id)}
disabled={replayingEvent === event.id}
title="Replay event (trigger matching hooks)"
>
<Play
className={cn('w-3.5 h-3.5', replayingEvent === event.id && 'animate-pulse')}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(event.id)}
title="Delete event"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* Expanded details */}
{expandedEvent === event.id && expandedEventData && (
<div className="px-4 pb-4 pt-0 border-t border-border/50">
<div className="mt-3 space-y-2 text-xs">
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-muted-foreground">Event ID:</span>
<p className="font-mono text-[10px] truncate">{expandedEventData.id}</p>
</div>
<div>
<span className="text-muted-foreground">Timestamp:</span>
<p>{new Date(expandedEventData.timestamp).toLocaleString()}</p>
</div>
{expandedEventData.featureId && (
<div>
<span className="text-muted-foreground">Feature ID:</span>
<p className="font-mono text-[10px] truncate">
{expandedEventData.featureId}
</p>
</div>
)}
{expandedEventData.passes !== undefined && (
<div>
<span className="text-muted-foreground">Passed:</span>
<p>{expandedEventData.passes ? 'Yes' : 'No'}</p>
</div>
)}
</div>
{expandedEventData.error && (
<div>
<span className="text-muted-foreground">Error:</span>
<p className="text-red-400 mt-1 p-2 bg-red-500/10 rounded text-[10px] font-mono whitespace-pre-wrap">
{expandedEventData.error}
</p>
</div>
)}
<div>
<span className="text-muted-foreground">Project:</span>
<p className="font-mono text-[10px] truncate">
{expandedEventData.projectPath}
</p>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Clear confirmation dialog */}
<ConfirmDialog
open={clearDialogOpen}
onOpenChange={setClearDialogOpen}
onConfirm={handleClearAll}
title="Clear Event History"
description={`This will permanently delete all ${events.length} recorded events. This action cannot be undone.`}
icon={Trash2}
iconClassName="text-destructive"
confirmText="Clear All"
confirmVariant="destructive"
/>
</div>
);
}

View File

@@ -39,6 +39,7 @@ interface EventHookDialogProps {
type ActionType = 'shell' | 'http';
const TRIGGER_OPTIONS: EventHookTrigger[] = [
'feature_created',
'feature_success',
'feature_error',
'auto_mode_complete',

View File

@@ -1,17 +1,20 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { Webhook, Plus, Trash2, Pencil, Terminal, Globe } from 'lucide-react';
import { Webhook, Plus, Trash2, Pencil, Terminal, Globe, History } from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import type { EventHook, EventHookTrigger } from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { EventHookDialog } from './event-hook-dialog';
import { EventHistoryView } from './event-history-view';
export function EventHooksSection() {
const { eventHooks, setEventHooks } = useAppStore();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingHook, setEditingHook] = useState<EventHook | null>(null);
const [activeTab, setActiveTab] = useState<'hooks' | 'history'>('hooks');
const handleAddHook = () => {
setEditingHook(null);
@@ -78,58 +81,85 @@ export function EventHooksSection() {
</p>
</div>
</div>
<Button onClick={handleAddHook} size="sm" className="gap-2">
<Plus className="w-4 h-4" />
Add Hook
</Button>
{activeTab === 'hooks' && (
<Button onClick={handleAddHook} size="sm" className="gap-2">
<Plus className="w-4 h-4" />
Add Hook
</Button>
)}
</div>
</div>
{/* Content */}
<div className="p-6">
{eventHooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Webhook className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No event hooks configured</p>
<p className="text-xs mt-1">
Add hooks to run commands or send webhooks when features complete
</p>
</div>
) : (
<div className="space-y-6">
{/* Group by trigger type */}
{Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
<div key={trigger} className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
{EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
</h3>
<div className="space-y-2">
{hooks.map((hook) => (
<HookCard
key={hook.id}
hook={hook}
onEdit={() => handleEditHook(hook)}
onDelete={() => handleDeleteHook(hook.id)}
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
/>
))}
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'hooks' | 'history')}>
<div className="px-6 pt-4">
<TabsList className="grid w-full max-w-xs grid-cols-2">
<TabsTrigger value="hooks" className="gap-2">
<Webhook className="w-4 h-4" />
Hooks
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="w-4 h-4" />
History
</TabsTrigger>
</TabsList>
</div>
{/* Hooks Tab */}
<TabsContent value="hooks" className="m-0">
<div className="p-6 pt-4">
{eventHooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Webhook className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No event hooks configured</p>
<p className="text-xs mt-1">
Add hooks to run commands or send webhooks when features complete
</p>
</div>
))}
) : (
<div className="space-y-6">
{/* Group by trigger type */}
{Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
<div key={trigger} className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
{EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
</h3>
<div className="space-y-2">
{hooks.map((hook) => (
<HookCard
key={hook.id}
hook={hook}
onEdit={() => handleEditHook(hook)}
onDelete={() => handleDeleteHook(hook.id)}
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Variable reference */}
<div className="px-6 pb-6">
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
<p className="font-medium mb-2">Available variables:</p>
<code className="text-[10px] leading-relaxed">
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
</code>
</div>
</div>
{/* Variable reference */}
<div className="px-6 pb-6">
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
<p className="font-medium mb-2">Available variables:</p>
<code className="text-[10px] leading-relaxed">
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
</code>
</div>
</div>
</TabsContent>
{/* History Tab */}
<TabsContent value="history" className="m-0">
<div className="p-6 pt-4">
<EventHistoryView />
</div>
</TabsContent>
</Tabs>
{/* Dialog */}
<EventHookDialog

View File

@@ -0,0 +1,78 @@
/**
* Hook to subscribe to notification WebSocket events and update the store.
*/
import { useEffect } from 'react';
import { useNotificationsStore } from '@/store/notifications-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { pathsEqual } from '@/lib/utils';
import type { Notification } from '@automaker/types';
/**
* Hook to subscribe to notification events and update the store.
* Should be used in a component that's always mounted when a project is open.
*/
export function useNotificationEvents(projectPath: string | null) {
const addNotification = useNotificationsStore((s) => s.addNotification);
useEffect(() => {
if (!projectPath) return;
const api = getHttpApiClient();
const unsubscribe = api.notifications.onNotificationCreated((notification: Notification) => {
// Only handle notifications for the current project
if (!pathsEqual(notification.projectPath, projectPath)) return;
addNotification(notification);
});
return unsubscribe;
}, [projectPath, addNotification]);
}
/**
* Hook to load notifications for a project.
* Should be called when switching projects or on initial load.
*/
export function useLoadNotifications(projectPath: string | null) {
const setNotifications = useNotificationsStore((s) => s.setNotifications);
const setUnreadCount = useNotificationsStore((s) => s.setUnreadCount);
const setLoading = useNotificationsStore((s) => s.setLoading);
const setError = useNotificationsStore((s) => s.setError);
const reset = useNotificationsStore((s) => s.reset);
useEffect(() => {
if (!projectPath) {
reset();
return;
}
const loadNotifications = async () => {
setLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const [listResult, countResult] = await Promise.all([
api.notifications.list(projectPath),
api.notifications.getUnreadCount(projectPath),
]);
if (listResult.success && listResult.notifications) {
setNotifications(listResult.notifications);
}
if (countResult.success && countResult.count !== undefined) {
setUnreadCount(countResult.count);
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to load notifications');
} finally {
setLoading(false);
}
};
loadNotifications();
}, [projectPath, setNotifications, setUnreadCount, setLoading, setError, reset]);
}

View File

@@ -550,6 +550,88 @@ export interface SaveImageResult {
error?: string;
}
// Notifications API interface
import type {
Notification,
StoredEvent,
StoredEventSummary,
EventHistoryFilter,
EventReplayResult,
} from '@automaker/types';
export interface NotificationsAPI {
list: (projectPath: string) => Promise<{
success: boolean;
notifications?: Notification[];
error?: string;
}>;
getUnreadCount: (projectPath: string) => Promise<{
success: boolean;
count?: number;
error?: string;
}>;
markAsRead: (
projectPath: string,
notificationId?: string
) => Promise<{
success: boolean;
notification?: Notification;
count?: number;
error?: string;
}>;
dismiss: (
projectPath: string,
notificationId?: string
) => Promise<{
success: boolean;
dismissed?: boolean;
count?: number;
error?: string;
}>;
}
// Event History API interface
export interface EventHistoryAPI {
list: (
projectPath: string,
filter?: EventHistoryFilter
) => Promise<{
success: boolean;
events?: StoredEventSummary[];
total?: number;
error?: string;
}>;
get: (
projectPath: string,
eventId: string
) => Promise<{
success: boolean;
event?: StoredEvent;
error?: string;
}>;
delete: (
projectPath: string,
eventId: string
) => Promise<{
success: boolean;
error?: string;
}>;
clear: (projectPath: string) => Promise<{
success: boolean;
cleared?: number;
error?: string;
}>;
replay: (
projectPath: string,
eventId: string,
hookIds?: string[]
) => Promise<{
success: boolean;
result?: EventReplayResult;
error?: string;
}>;
}
export interface ElectronAPI {
ping: () => Promise<string>;
getApiKey?: () => Promise<string | null>;
@@ -760,6 +842,8 @@ export interface ElectronAPI {
}>;
};
ideation?: IdeationAPI;
notifications?: NotificationsAPI;
eventHistory?: EventHistoryAPI;
codex?: {
getUsage: () => Promise<CodexUsageResponse>;
getModels: (refresh?: boolean) => Promise<{

View File

@@ -32,7 +32,10 @@ import type {
CreateIdeaInput,
UpdateIdeaInput,
ConvertToFeatureOptions,
NotificationsAPI,
EventHistoryAPI,
} from './electron';
import type { EventHistoryFilter } from '@automaker/types';
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
@@ -514,7 +517,8 @@ type EventType =
| 'worktree:init-completed'
| 'dev-server:started'
| 'dev-server:output'
| 'dev-server:stopped';
| 'dev-server:stopped'
| 'notification:created';
/**
* Dev server log event payloads for WebSocket streaming
@@ -2440,6 +2444,43 @@ export class HttpApiClient implements ElectronAPI {
},
};
// Notifications API - project-level notifications
notifications: NotificationsAPI & {
onNotificationCreated: (callback: (notification: any) => void) => () => void;
} = {
list: (projectPath: string) => this.post('/api/notifications/list', { projectPath }),
getUnreadCount: (projectPath: string) =>
this.post('/api/notifications/unread-count', { projectPath }),
markAsRead: (projectPath: string, notificationId?: string) =>
this.post('/api/notifications/mark-read', { projectPath, notificationId }),
dismiss: (projectPath: string, notificationId?: string) =>
this.post('/api/notifications/dismiss', { projectPath, notificationId }),
onNotificationCreated: (callback: (notification: any) => void): (() => void) => {
return this.subscribeToEvent('notification:created', callback as EventCallback);
},
};
// Event History API - stored events for debugging and replay
eventHistory: EventHistoryAPI = {
list: (projectPath: string, filter?: EventHistoryFilter) =>
this.post('/api/event-history/list', { projectPath, filter }),
get: (projectPath: string, eventId: string) =>
this.post('/api/event-history/get', { projectPath, eventId }),
delete: (projectPath: string, eventId: string) =>
this.post('/api/event-history/delete', { projectPath, eventId }),
clear: (projectPath: string) => this.post('/api/event-history/clear', { projectPath }),
replay: (projectPath: string, eventId: string, hookIds?: string[]) =>
this.post('/api/event-history/replay', { projectPath, eventId, hookIds }),
};
// MCP API - Test MCP server connections and list tools
// SECURITY: Only accepts serverId, not arbitrary serverConfig, to prevent
// drive-by command execution attacks. Servers must be saved first.

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { NotificationsView } from '@/components/views/notifications-view';
export const Route = createFileRoute('/notifications')({
component: NotificationsView,
});

View File

@@ -233,6 +233,7 @@ export interface KeyboardShortcuts {
settings: string;
terminal: string;
ideation: string;
notifications: string;
githubIssues: string;
githubPrs: string;
@@ -268,6 +269,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
settings: 'S',
terminal: 'T',
ideation: 'I',
notifications: 'X',
githubIssues: 'G',
githubPrs: 'R',

View File

@@ -0,0 +1,129 @@
/**
* Notifications Store - State management for project-level notifications
*/
import { create } from 'zustand';
import type { Notification } from '@automaker/types';
// ============================================================================
// State Interface
// ============================================================================
interface NotificationsState {
// Notifications for the current project
notifications: Notification[];
unreadCount: number;
isLoading: boolean;
error: string | null;
// Popover state
isPopoverOpen: boolean;
}
// ============================================================================
// Actions Interface
// ============================================================================
interface NotificationsActions {
// Data management
setNotifications: (notifications: Notification[]) => void;
setUnreadCount: (count: number) => void;
addNotification: (notification: Notification) => void;
markAsRead: (notificationId: string) => void;
markAllAsRead: () => void;
dismissNotification: (notificationId: string) => void;
dismissAll: () => void;
// Loading state
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
// Popover state
setPopoverOpen: (open: boolean) => void;
// Reset
reset: () => void;
}
// ============================================================================
// Initial State
// ============================================================================
const initialState: NotificationsState = {
notifications: [],
unreadCount: 0,
isLoading: false,
error: null,
isPopoverOpen: false,
};
// ============================================================================
// Store
// ============================================================================
export const useNotificationsStore = create<NotificationsState & NotificationsActions>(
(set, get) => ({
...initialState,
// Data management
setNotifications: (notifications) =>
set({
notifications,
unreadCount: notifications.filter((n) => !n.read).length,
}),
setUnreadCount: (count) => set({ unreadCount: count }),
addNotification: (notification) =>
set((state) => ({
notifications: [notification, ...state.notifications],
unreadCount: notification.read ? state.unreadCount : state.unreadCount + 1,
})),
markAsRead: (notificationId) =>
set((state) => {
const notification = state.notifications.find((n) => n.id === notificationId);
if (!notification || notification.read) return state;
return {
notifications: state.notifications.map((n) =>
n.id === notificationId ? { ...n, read: true } : n
),
unreadCount: Math.max(0, state.unreadCount - 1),
};
}),
markAllAsRead: () =>
set((state) => ({
notifications: state.notifications.map((n) => ({ ...n, read: true })),
unreadCount: 0,
})),
dismissNotification: (notificationId) =>
set((state) => {
const notification = state.notifications.find((n) => n.id === notificationId);
if (!notification) return state;
return {
notifications: state.notifications.filter((n) => n.id !== notificationId),
unreadCount: notification.read ? state.unreadCount : Math.max(0, state.unreadCount - 1),
};
}),
dismissAll: () =>
set({
notifications: [],
unreadCount: 0,
}),
// Loading state
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
// Popover state
setPopoverOpen: (open) => set({ isPopoverOpen: open }),
// Reset
reset: () => set(initialState),
})
);