Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { parseCron, toCron } from '@renderer/lib/CronPicker/cron-utils';
import { builtinAutomationCatalog } from '@shared/automations/builtin-catalog';

describe('automation template crons', () => {
it('keeps every builtin template cron editable in the CronPicker', () => {
for (const template of builtinAutomationCatalog) {
expect(parseCron(template.defaultTrigger.expr), template.id).not.toBeNull();
}
});

it('round-trips every builtin template cron through the CronPicker unchanged', () => {
for (const template of builtinAutomationCatalog) {
const state = parseCron(template.defaultTrigger.expr);
if (!state) continue;
expect(toCron(state), template.id).toBe(template.defaultTrigger.expr);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {
Accessibility,
BookOpen,
Bug,
CalendarDays,
Eraser,
FileText,
Flag,
FlaskConical,
Gauge,
KeyRound,
ListTodo,
type LucideIcon,
Mail,
PackageOpen,
Repeat,
Rocket,
ScrollText,
Search,
ShieldCheck,
Wrench,
} from 'lucide-react';
import { useState } from 'react';
import { CardGrid, CardGridItem } from '@renderer/lib/components/card-grid';
import { MicroLabel } from '@renderer/lib/ui/label';
import { PanelTabs } from '@renderer/lib/ui/panel-tabs';
import type { BuiltinAutomationTemplate } from '@shared/automations/automation';
import {
automationCatalogCategories,
builtinAutomationTemplatesByCategory,
popularAutomationTemplates,
type AutomationCatalogCategory,
type AutomationTemplateIcon,
type CatalogTemplate,
} from '@shared/automations/builtin-catalog';

const templateIcons: Record<AutomationTemplateIcon, LucideIcon> = {
Accessibility,
BookOpen,
Bug,
CalendarDays,
Eraser,
FileText,
Flag,
FlaskConical,
Gauge,
KeyRound,
ListTodo,
Mail,
PackageOpen,
Repeat,
Rocket,
ScrollText,
Search,
ShieldCheck,
Wrench,
};

const POPULAR_TAB = 'Popular';
type GalleryTab = typeof POPULAR_TAB | AutomationCatalogCategory;

const galleryTabValues: GalleryTab[] = [POPULAR_TAB, ...automationCatalogCategories];
const galleryTabs = galleryTabValues.map((category) => ({
value: category,
label: category,
}));

interface AutomationTemplateGalleryProps {
onSelectTemplate: (template: BuiltinAutomationTemplate) => void;
}

export function AutomationTemplateGallery({ onSelectTemplate }: AutomationTemplateGalleryProps) {
const [activeTab, setActiveTab] = useState<GalleryTab>(POPULAR_TAB);
const templates =
activeTab === POPULAR_TAB
? popularAutomationTemplates
: builtinAutomationTemplatesByCategory[activeTab];

return (
<div className="flex flex-col gap-2.5">
<MicroLabel>Templates</MicroLabel>
<PanelTabs compact value={activeTab} onChange={setActiveTab} tabs={galleryTabs} />
<CardGrid className="sm:grid-cols-2">
{templates.map((template) => (
<AutomationTemplateCard
key={template.id}
template={template}
onSelect={onSelectTemplate}
/>
))}
</CardGrid>
</div>
);
}

interface AutomationTemplateCardProps {
template: CatalogTemplate;
onSelect: (template: BuiltinAutomationTemplate) => void;
}

function AutomationTemplateCard({ template, onSelect }: AutomationTemplateCardProps) {
const Icon = templateIcons[template.icon];
return (
<CardGridItem
role="button"
tabIndex={0}
onClick={() => onSelect(template)}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onSelect(template);
}}
className="h-full flex-col items-start gap-1.5 p-3 outline-none"
>
<div className="flex w-full min-w-0 items-center gap-2">
<Icon className="size-3.5 shrink-0 text-foreground-muted" aria-hidden="true" />
<h3
className="line-clamp-1 min-w-0 text-sm leading-5 font-medium text-foreground"
title={template.name}
>
{template.name}
</h3>
</div>
<p className="line-clamp-2 text-xs leading-relaxed text-foreground-muted">
{template.description}
</p>
</CardGridItem>
);
}
77 changes: 63 additions & 14 deletions src/renderer/features/automations/components/AutomationsView.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from '@renderer/lib/layout/navigation-provider';
import { useShowModal } from '@renderer/lib/modal/modal-provider';
import { EmptyState } from '@renderer/lib/ui/empty-state';
import { Sheet, SheetContent } from '@renderer/lib/ui/sheet';
import type { Automation } from '@shared/automations/automation';
import type { Automation, BuiltinAutomationTemplate } from '@shared/automations/automation';
import { useAutomations } from '../use-automations';
import { AutomationDetailView } from './AutomationDetailView';
import { AutomationsHeader } from './AutomationsHeader';
import { AutomationsList } from './AutomationsList';
import { AutomationTemplateGallery } from './AutomationTemplateGallery';
import { CreateAutomationView } from './CreateAutomationView';

type SheetState =
| { kind: 'create'; template: BuiltinAutomationTemplate | null }
| { kind: 'edit'; automationId: string }
| null;

export function AutomationsView() {
const { automations, toggleEnabled, destroy } = useAutomations();
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
const [sheetState, setSheetState] = useState<SheetState>(null);
const showConfirm = useShowModal('confirmActionModal');
const { navigate } = useNavigate();
const { params, setParams } = useParams('automations');
Expand All @@ -23,13 +30,30 @@ export function AutomationsView() {
[automations.data, search]
);

const liveAutomation = params.automationId
? (automations.data?.find((a) => a.id === params.automationId) ?? null)
: null;
const hasAutomations = (automations.data?.length ?? 0) > 0;

const sheetAutomationId =
sheetState?.kind === 'edit' ? sheetState.automationId : params.automationId;
const liveAutomation =
sheetState?.kind === 'create'
? null
: sheetAutomationId
? (automations.data?.find((a) => a.id === sheetAutomationId) ?? null)
: null;

function openCreate(template: BuiltinAutomationTemplate | null) {
setParams({ automationId: undefined });
setSheetState({ kind: 'create', template });
}

function openEdit(automation: Automation) {
setSheetState({ kind: 'edit', automationId: automation.id });
navigate('automations', { automationId: automation.id });
}

function closeSheet() {
setParams({ automationId: undefined });
setCreating(false);
setSheetState(null);
}

function handleToggleEnabled(automation: Automation, enabled: boolean) {
Expand All @@ -56,22 +80,47 @@ export function AutomationsView() {
search={search}
onSearchChange={setSearch}
createPending={false}
onNewAutomation={() => setCreating(true)}
/>
<AutomationsList
automations={effectiveAutomations}
onEdit={(automation) => navigate('automations', { automationId: automation.id })}
onToggleEnabled={handleToggleEnabled}
onNewAutomation={() => openCreate(null)}
/>
{hasAutomations && effectiveAutomations.length === 0 ? (
<EmptyState
label="No matches"
description="No automations match your search."
className="min-h-32 py-8"
/>
) : (
<AutomationsList
automations={effectiveAutomations}
onEdit={openEdit}
onToggleEnabled={handleToggleEnabled}
/>
)}
{!hasAutomations && automations.isSuccess && (
<EmptyState
label="No automations yet"
description="Run agents on a schedule. Start from a template below or create your own."
className="min-h-32 py-8"
/>
)}
<div className="mt-8">
<AutomationTemplateGallery onSelectTemplate={(template) => openCreate(template)} />
</div>
</div>
</div>
</div>
<Sheet
open={liveAutomation !== null || creating}
open={liveAutomation !== null || sheetState?.kind === 'create'}
onOpenChange={(open) => !open && closeSheet()}
>
<SheetContent showCloseButton={false}>
{creating && <CreateAutomationView onClose={closeSheet} onSaved={closeSheet} />}
{sheetState?.kind === 'create' && (
<CreateAutomationView
key={sheetState.template?.id ?? 'scratch'}
template={sheetState.template ?? undefined}
onClose={closeSheet}
onSaved={closeSheet}
/>
)}
{liveAutomation && (
<AutomationDetailView
automation={liveAutomation}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { EditableNameField } from '@renderer/lib/ui/editable-name-field';
import { Field } from '@renderer/lib/ui/field';
import { Label } from '@renderer/lib/ui/label';
import { SheetFooter } from '@renderer/lib/ui/sheet';
import type { Automation } from '@shared/automations/automation';
import type { Automation, BuiltinAutomationTemplate } from '@shared/automations/automation';
import type { ConversationConfig } from '@shared/automations/config';
import { formatAutomationError } from '@shared/automations/format';
import { assertValidCronTrigger } from '@shared/automations/validation';
Expand All @@ -18,15 +18,17 @@ import { AutomationSettingsFields } from './AutomationSettingsFields';
import { SheetHeader } from './sheet-header';

export interface CreateAutomationViewProps {
template?: BuiltinAutomationTemplate;
onClose: () => void;
onSaved?: (automation: Automation) => void;
}

export const CreateAutomationView = observer(function CreateAutomationView({
template,
onClose,
onSaved,
}: CreateAutomationViewProps) {
const formState = useAutomationFormState();
const formState = useAutomationFormState(undefined, template);
const {
name,
setName,
Expand Down

This file was deleted.

Loading
Loading