diff --git a/apps/emdash-desktop/src/main/core/automations/automations-service.ts b/apps/emdash-desktop/src/main/core/automations/automations-service.ts index 292c079de5..4e7583d4ba 100644 --- a/apps/emdash-desktop/src/main/core/automations/automations-service.ts +++ b/apps/emdash-desktop/src/main/core/automations/automations-service.ts @@ -1,4 +1,17 @@ -import { and, asc, count, desc, eq, ne, sql } from 'drizzle-orm'; +import { + and, + asc, + count, + desc, + eq, + gt, + inArray, + isNotNull, + isNull, + ne, + or, + sql, +} from 'drizzle-orm'; import { db } from '@main/db/client'; import { automationRuns, tasks } from '@main/db/schema'; import { events } from '@main/lib/events'; @@ -9,7 +22,11 @@ import type { CreateAutomationParams, UpdateAutomationSettingsPatch, } from '@shared/core/automations/automation'; -import type { AutomationRun } from '@shared/core/automations/automation-run'; +import { + AUTOMATION_SKIP_CODES_EXCLUDED_FROM_NOTIFICATIONS, + TERMINAL_AUTOMATION_RUN_STATUSES, + type AutomationRun, +} from '@shared/core/automations/automation-run'; import { automationChangedChannel, automationRunChangedChannel, @@ -31,6 +48,24 @@ import { import { markRunSkipped } from './run-transitions'; import { mapAutomationRunRowToAutomationRun } from './utils'; +function notificationRunPredicate() { + return and( + inArray(automationRuns.status, [...TERMINAL_AUTOMATION_RUN_STATUSES]), + isNotNull(automationRuns.finishedAt) + ); +} + +function isExcludedFromUnreadNotifications() { + const excludedInList = AUTOMATION_SKIP_CODES_EXCLUDED_FROM_NOTIFICATIONS.map( + (code) => `'${code}'` + ).join(', '); + return or( + ne(automationRuns.status, 'skipped'), + isNull(sql`json_extract(${automationRuns.error}, '$.code')`), + sql`json_extract(${automationRuns.error}, '$.code') NOT IN (${sql.raw(excludedInList)})` + ); +} + export type AutomationsServiceHooks = { 'automation:created': (automation: Automation) => void | Promise; 'automation:updated': (automation: Automation) => void | Promise; @@ -49,13 +84,21 @@ export class AutomationsService implements Hookable { private readonly scheduler = new AutomationScheduler({ onRunStep: (run) => { this._hooks.callHookBackground('run:step-completed', run); - events.emit(automationRunChangedChannel, { automationId: run.automationId, run }); + this.emitRunChanged(run); }, onScheduledRunChanged: (automationId) => { events.emit(automationChangedChannel, { automationId }); }, }); + private emitRunChanged(run: AutomationRun): void { + events.emit(automationRunChangedChannel, { automationId: run.automationId, run }); + } + + private emitRunChanges(runs: AutomationRun[]): void { + for (const run of runs) this.emitRunChanged(run); + } + on(name: K, handler: AutomationsServiceHooks[K]) { return this._hooks.on(name, handler); } @@ -91,7 +134,8 @@ export class AutomationsService implements Hookable { const automation = await updateSettingsInRepo(id, patch); if (!automation) throw new Error('automation_not_found'); if (patch.triggerConfig !== undefined) { - await skipQueuedCronRuns(id, 'trigger_changed'); + const skippedRuns = await skipQueuedCronRuns(id, 'trigger_changed'); + this.emitRunChanges(skippedRuns); if (automation.enabled) { await ensureNextCronRun(automation); events.emit(automationChangedChannel, { automationId: id }); @@ -121,7 +165,8 @@ export class AutomationsService implements Hookable { if (enabled) { await ensureNextCronRun(automation); } else { - await skipQueuedCronRuns(id, 'disabled'); + const skippedRuns = await skipQueuedCronRuns(id, 'disabled'); + this.emitRunChanges(skippedRuns); } this._hooks.callHookBackground('automation:enabled', automation); events.emit(automationChangedChannel, { automationId: id }); @@ -151,6 +196,28 @@ export class AutomationsService implements Hookable { return rows.map(({ run, taskId }) => mapAutomationRunRowToAutomationRun(run, taskId)); } + async getNotificationsBaselineTimestamp(): Promise { + const [row] = await db + .select({ ts: sql`MAX(${automationRuns.finishedAt})` }) + .from(automationRuns) + .where(notificationRunPredicate()); + return row?.ts ?? Date.now(); + } + + async countUnreadFinishedRuns(sinceMs: number): Promise { + const [result] = await db + .select({ count: count() }) + .from(automationRuns) + .where( + and( + notificationRunPredicate(), + isExcludedFromUnreadNotifications(), + gt(automationRuns.finishedAt, sinceMs) + ) + ); + return result?.count ?? 0; + } + async countAutomationRunsByStatus( automationId: string ): Promise<{ all: number; done: number; failed: number; skipped: number }> { @@ -238,6 +305,7 @@ export class AutomationsService implements Hookable { } this._hooks.callHookBackground('run:stopped', stopped); this._hooks.callHookBackground('run:step-completed', stopped); + this.emitRunChanged(stopped); return stopped; } @@ -246,7 +314,8 @@ export class AutomationsService implements Hookable { } async deleteAutomation(id: string): Promise { - await skipQueuedCronRuns(id, 'automation_deleted'); + const skippedRuns = await skipQueuedCronRuns(id, 'automation_deleted'); + this.emitRunChanges(skippedRuns); const deleted = await softDeleteAutomation(id); if (!deleted) throw new Error('automation_not_found'); this._hooks.callHookBackground('automation:deleted', id); diff --git a/apps/emdash-desktop/src/main/core/automations/controller.ts b/apps/emdash-desktop/src/main/core/automations/controller.ts index ea76812721..c8ba61b8b5 100644 --- a/apps/emdash-desktop/src/main/core/automations/controller.ts +++ b/apps/emdash-desktop/src/main/core/automations/controller.ts @@ -11,6 +11,9 @@ export const automationsController = createRPCController({ listAutomationRuns: automationsService.listAutomationRuns.bind(automationsService), countAutomationRunsByStatus: automationsService.countAutomationRunsByStatus.bind(automationsService), + countUnreadFinishedRuns: automationsService.countUnreadFinishedRuns.bind(automationsService), + getNotificationsBaselineTimestamp: + automationsService.getNotificationsBaselineTimestamp.bind(automationsService), getLatestRun: automationsService.getLatestRun.bind(automationsService), getNextScheduledRun: automationsService.getNextScheduledRun.bind(automationsService), runAutomation: automationsService.runAutomation.bind(automationsService), diff --git a/apps/emdash-desktop/src/main/core/settings/schema.ts b/apps/emdash-desktop/src/main/core/settings/schema.ts index 96701939f4..82bedc1ab8 100644 --- a/apps/emdash-desktop/src/main/core/settings/schema.ts +++ b/apps/emdash-desktop/src/main/core/settings/schema.ts @@ -91,6 +91,8 @@ export const interfaceSettingsSchema = z.object({ showLeftSidebarTimestamps: z.boolean(), confirmTabClose: z.boolean(), hideContextBar: z.boolean(), + /** Timestamp (ms) of the last time the user viewed automations; used for unread run badges. */ + automationsLastReadAt: z.number(), }); export const changesViewModeSchema = z.object({ diff --git a/apps/emdash-desktop/src/main/core/settings/settings-registry.ts b/apps/emdash-desktop/src/main/core/settings/settings-registry.ts index 2c6a83ccae..6212d8d9b9 100644 --- a/apps/emdash-desktop/src/main/core/settings/settings-registry.ts +++ b/apps/emdash-desktop/src/main/core/settings/settings-registry.ts @@ -60,6 +60,7 @@ export const SETTINGS_DEFAULTS = { showLeftSidebarTimestamps: true, confirmTabClose: false, hideContextBar: false, + automationsLastReadAt: 0, }, browserPreview: { enabled: true, diff --git a/apps/emdash-desktop/src/renderer/features/automations/use-automation-unread-count.ts b/apps/emdash-desktop/src/renderer/features/automations/use-automation-unread-count.ts new file mode 100644 index 0000000000..1cb339cedb --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/automations/use-automation-unread-count.ts @@ -0,0 +1,47 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect } from 'react'; +import { useAppSettingsKey } from '@renderer/features/settings/use-app-settings-key'; +import { events, rpc } from '@renderer/lib/ipc'; +import { isTerminalAutomationRunStatus } from '@shared/core/automations/automation-run'; +import { automationRunChangedChannel } from '@shared/core/automations/automationEvents'; + +export const automationUnreadCountQueryKey = (lastReadAt: number) => + ['automations', 'unread-count', lastReadAt] as const; + +export function useAutomationUnreadCount() { + const { value: interfaceSettings, updateAsync } = useAppSettingsKey('interface'); + const lastReadAt = interfaceSettings?.automationsLastReadAt ?? 0; + const queryClient = useQueryClient(); + + useEffect(() => { + if (lastReadAt !== 0 || interfaceSettings === undefined) return; + void rpc.automations.getNotificationsBaselineTimestamp().then((baseline) => { + void updateAsync({ automationsLastReadAt: baseline }); + }); + }, [interfaceSettings, lastReadAt, updateAsync]); + + const query = useQuery({ + queryKey: automationUnreadCountQueryKey(lastReadAt), + queryFn: () => rpc.automations.countUnreadFinishedRuns(lastReadAt), + enabled: lastReadAt > 0, + }); + + useEffect(() => { + return events.on(automationRunChangedChannel, ({ run }) => { + if (!isTerminalAutomationRunStatus(run.status)) return; + void queryClient.invalidateQueries({ queryKey: ['automations', 'unread-count'] }); + }); + }, [queryClient]); + + return query.data ?? 0; +} + +export function useMarkAutomationsRead() { + const { updateAsync } = useAppSettingsKey('interface'); + const queryClient = useQueryClient(); + + return useCallback(async () => { + await updateAsync({ automationsLastReadAt: Date.now() }); + void queryClient.invalidateQueries({ queryKey: ['automations', 'unread-count'] }); + }, [updateAsync, queryClient]); +} diff --git a/apps/emdash-desktop/src/renderer/features/sidebar/automations-sidebar-item.tsx b/apps/emdash-desktop/src/renderer/features/sidebar/automations-sidebar-item.tsx new file mode 100644 index 0000000000..508c165771 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/sidebar/automations-sidebar-item.tsx @@ -0,0 +1,94 @@ +import { CheckCheck, Clock } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import { useEffect, useState } from 'react'; +import { + useAutomationUnreadCount, + useMarkAutomationsRead, +} from '@renderer/features/automations/use-automation-unread-count'; +import { toast } from '@renderer/lib/hooks/use-toast'; +import { + isCurrentView, + useNavigate, + useWorkspaceSlots, +} from '@renderer/lib/layout/navigation-provider'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from '@renderer/lib/ui/context-menu'; +import { cn } from '@renderer/utils/utils'; +import { SidebarMenuAction, SidebarMenuRow } from './sidebar-primitives'; + +function formatUnreadCount(count: number): string { + if (count > 99) return '99+'; + return String(count); +} + +export const AutomationsSidebarItem = observer(function AutomationsSidebarItem() { + const { navigate } = useNavigate(); + const { currentView } = useWorkspaceSlots(); + const unreadCount = useAutomationUnreadCount(); + const markAsRead = useMarkAutomationsRead(); + const isActive = isCurrentView(currentView, 'automations'); + const [menuOpen, setMenuOpen] = useState(false); + + useEffect(() => { + if (unreadCount === 0) setMenuOpen(false); + }, [unreadCount]); + + async function handleMarkAsRead() { + try { + await markAsRead(); + setMenuOpen(false); + } catch { + toast({ + title: 'Could not mark as read', + description: 'Your read state could not be saved. Please try again.', + variant: 'destructive', + }); + } + } + + return ( + { + if (open && unreadCount === 0) return; + setMenuOpen(open); + }} + > + + e.preventDefault()} + onClick={() => navigate('automations')} + > + + + Automations + + {unreadCount > 0 ? ( + + {formatUnreadCount(unreadCount)} + + ) : null} + + + + void handleMarkAsRead()}> + + Mark all as read + + + + ); +}); diff --git a/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx b/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx index 676bd7c987..73744ea446 100644 --- a/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx @@ -1,4 +1,4 @@ -import { Clock, FolderInput, Library, MessageSquareShare, Settings } from 'lucide-react'; +import { FolderInput, Library, MessageSquareShare, Settings } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import React from 'react'; import { @@ -9,6 +9,7 @@ import { import { useShowModal } from '@renderer/lib/modal/modal-provider'; import { BoundShortcut } from '@renderer/lib/ui/shortcut'; import { cn } from '@renderer/utils/utils'; +import { AutomationsSidebarItem } from './automations-sidebar-item'; import { SidebarPinnedTaskList } from './pinned-task-list'; import { ProjectsGroupLabel } from './projects-group-label'; import { @@ -66,17 +67,7 @@ export const LeftSidebar: React.FC = observer(function LeftSidebar() { - navigate('automations')} - aria-label="Automations" - className="w-full justify-between" - > - - - Automations - - +