Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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<void>;
'automation:updated': (automation: Automation) => void | Promise<void>;
Expand All @@ -49,13 +84,21 @@ export class AutomationsService implements Hookable<AutomationsServiceHooks> {
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);
}
Comment thread
janburzinski marked this conversation as resolved.

on<K extends keyof AutomationsServiceHooks>(name: K, handler: AutomationsServiceHooks[K]) {
return this._hooks.on(name, handler);
}
Expand Down Expand Up @@ -91,7 +134,8 @@ export class AutomationsService implements Hookable<AutomationsServiceHooks> {
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 });
Expand Down Expand Up @@ -121,7 +165,8 @@ export class AutomationsService implements Hookable<AutomationsServiceHooks> {
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 });
Expand Down Expand Up @@ -151,6 +196,28 @@ export class AutomationsService implements Hookable<AutomationsServiceHooks> {
return rows.map(({ run, taskId }) => mapAutomationRunRowToAutomationRun(run, taskId));
}

async getNotificationsBaselineTimestamp(): Promise<number> {
const [row] = await db
.select({ ts: sql<number | null>`MAX(${automationRuns.finishedAt})` })
.from(automationRuns)
.where(notificationRunPredicate());
return row?.ts ?? Date.now();
}

async countUnreadFinishedRuns(sinceMs: number): Promise<number> {
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 }> {
Expand Down Expand Up @@ -238,6 +305,7 @@ export class AutomationsService implements Hookable<AutomationsServiceHooks> {
}
this._hooks.callHookBackground('run:stopped', stopped);
this._hooks.callHookBackground('run:step-completed', stopped);
this.emitRunChanged(stopped);
return stopped;
}

Expand All @@ -246,7 +314,8 @@ export class AutomationsService implements Hookable<AutomationsServiceHooks> {
}

async deleteAutomation(id: string): Promise<void> {
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);
Expand Down
3 changes: 3 additions & 0 deletions apps/emdash-desktop/src/main/core/automations/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions apps/emdash-desktop/src/main/core/settings/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const SETTINGS_DEFAULTS = {
showLeftSidebarTimestamps: true,
confirmTabClose: false,
hideContextBar: false,
automationsLastReadAt: 0,
},
browserPreview: {
enabled: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -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]);
Comment thread
janburzinski marked this conversation as resolved.

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]);
}
Original file line number Diff line number Diff line change
@@ -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',
});
}
}
Comment thread
janburzinski marked this conversation as resolved.

return (
<ContextMenu
open={menuOpen}
onOpenChange={(open) => {
if (open && unreadCount === 0) return;
setMenuOpen(open);
}}
>
<ContextMenuTrigger className="w-full">
<SidebarMenuRow
isActive={isActive}
aria-label="Automations"
className="w-full justify-between"
onMouseDown={(e) => e.preventDefault()}
onClick={() => navigate('automations')}
>
<SidebarMenuAction aria-label="Automations" className="gap-2">
<Clock className="h-5 w-5 shrink-0 sm:h-4 sm:w-4" />
<span className="truncate">Automations</span>
</SidebarMenuAction>
{unreadCount > 0 ? (
<span
aria-label={`${unreadCount} unread automation run${unreadCount === 1 ? '' : 's'}`}
className={cn(
'ml-2 inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full',
'bg-background-tertiary-2 px-1.5 text-[10px] font-medium tabular-nums text-foreground-tertiary'
)}
>
{formatUnreadCount(unreadCount)}
</span>
) : null}
</SidebarMenuRow>
</ContextMenuTrigger>
<ContextMenuContent side="bottom" align="start">
<ContextMenuItem onClick={() => void handleMarkAsRead()}>
<CheckCheck className="size-4" />
Mark all as read
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
});
15 changes: 3 additions & 12 deletions apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -66,17 +67,7 @@ export const LeftSidebar: React.FC = observer(function LeftSidebar() {
<SidebarFooter>
<SidebarMenu>
<SidebarSearchTrigger />
<SidebarMenuButton
isActive={isCurrentView(currentView, 'automations')}
onClick={() => navigate('automations')}
aria-label="Automations"
className="w-full justify-between"
>
<span className="flex min-w-0 items-center gap-2">
<Clock className="h-5 w-5 shrink-0 sm:h-4 sm:w-4" />
<span className="truncate">Automations</span>
</span>
</SidebarMenuButton>
<AutomationsSidebarItem />
<SidebarMenuButton
isActive={
isCurrentView(currentView, 'library') ||
Expand Down
16 changes: 16 additions & 0 deletions apps/emdash-desktop/src/shared/core/automations/automation-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ export type RunError = {
message?: string; // supplementary context (branch name, timeout ms, etc.)
};

export const TERMINAL_AUTOMATION_RUN_STATUSES = ['done', 'failed', 'skipped'] as const;

export type TerminalAutomationRunStatus = (typeof TERMINAL_AUTOMATION_RUN_STATUSES)[number];

export function isTerminalAutomationRunStatus(
status: AutomationRunStatus
): status is TerminalAutomationRunStatus {
return (TERMINAL_AUTOMATION_RUN_STATUSES as readonly AutomationRunStatus[]).includes(status);
}

/** Skip reasons that reflect user-initiated lifecycle changes, not actionable run outcomes. */
export const AUTOMATION_SKIP_CODES_EXCLUDED_FROM_NOTIFICATIONS = [
'disabled',
'automation_deleted',
] as const;

export type AutomationRun = {
id: string;
automationId: string;
Expand Down
Loading