mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user