diff --git a/apps/code/src/renderer/api/posthogClient.test.ts b/apps/code/src/renderer/api/posthogClient.test.ts index f684aa266..7e5b01bac 100644 --- a/apps/code/src/renderer/api/posthogClient.test.ts +++ b/apps/code/src/renderer/api/posthogClient.test.ts @@ -385,4 +385,108 @@ describe("PostHogAPIClient", () => { expect(result.length).toBe(50); }); }); + + describe("Task automations", () => { + function buildAutomationClient() { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + const api = { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }; + (client as unknown as { api: typeof api }).api = api; + return { client, api }; + } + + it("listTaskAutomations returns the paginated results array", async () => { + const { client, api } = buildAutomationClient(); + api.get.mockResolvedValue({ results: [{ id: "a" }, { id: "b" }] }); + const result = await client.listTaskAutomations(); + expect(api.get).toHaveBeenCalledWith( + "/api/projects/{project_id}/task_automations/", + expect.objectContaining({ + path: { project_id: "123" }, + query: {}, + }), + ); + expect(result).toEqual([{ id: "a" }, { id: "b" }]); + }); + + it("listTaskAutomations handles a missing results field", async () => { + const { client, api } = buildAutomationClient(); + api.get.mockResolvedValue({}); + expect(await client.listTaskAutomations()).toEqual([]); + }); + + it("createTaskAutomation POSTs the input body to the team-scoped path", async () => { + const { client, api } = buildAutomationClient(); + api.post.mockResolvedValue({ id: "new" }); + const result = await client.createTaskAutomation({ + name: "Daily audit", + prompt: "Audit my flags", + cron_expression: "0 9 * * *", + repository: "", + timezone: "America/New_York", + enabled: true, + }); + expect(api.post).toHaveBeenCalledWith( + "/api/projects/{project_id}/task_automations/", + expect.objectContaining({ + path: { project_id: "123" }, + body: expect.objectContaining({ + name: "Daily audit", + prompt: "Audit my flags", + cron_expression: "0 9 * * *", + repository: "", + timezone: "America/New_York", + enabled: true, + }), + }), + ); + expect(result).toEqual({ id: "new" }); + }); + + it("updateTaskAutomation PATCHes the team-and-id-scoped path", async () => { + const { client, api } = buildAutomationClient(); + api.patch.mockResolvedValue({ id: "abc" }); + await client.updateTaskAutomation("abc", { enabled: false }); + expect(api.patch).toHaveBeenCalledWith( + "/api/projects/{project_id}/task_automations/{id}/", + expect.objectContaining({ + path: { project_id: "123", id: "abc" }, + body: { enabled: false }, + }), + ); + }); + + it("deleteTaskAutomation DELETEs the team-and-id-scoped path", async () => { + const { client, api } = buildAutomationClient(); + api.delete.mockResolvedValue(undefined); + await client.deleteTaskAutomation("abc"); + expect(api.delete).toHaveBeenCalledWith( + "/api/projects/{project_id}/task_automations/{id}/", + expect.objectContaining({ + path: { project_id: "123", id: "abc" }, + }), + ); + }); + + it("runTaskAutomationNow POSTs to the /run/ endpoint", async () => { + const { client, api } = buildAutomationClient(); + api.post.mockResolvedValue({ id: "abc" }); + await client.runTaskAutomationNow("abc"); + expect(api.post).toHaveBeenCalledWith( + "/api/projects/{project_id}/task_automations/{id}/run/", + expect.objectContaining({ + path: { project_id: "123", id: "abc" }, + }), + ); + }); + }); }); diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 011494253..cfafe08b4 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1013,6 +1013,77 @@ export class PostHogAPIClient { }); } + async listTaskAutomations(): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.get( + `/api/projects/{project_id}/task_automations/`, + { + path: { project_id: teamId.toString() }, + query: {}, + }, + ); + return data.results ?? []; + } + + async createTaskAutomation( + input: Pick< + Schemas.TaskAutomation, + "name" | "prompt" | "cron_expression" | "repository" + > & + Partial< + Pick< + Schemas.TaskAutomation, + "github_integration" | "timezone" | "template_id" | "enabled" + > + >, + ): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.post( + `/api/projects/{project_id}/task_automations/`, + { + path: { project_id: teamId.toString() }, + body: input as unknown as Schemas.TaskAutomation, + }, + ); + return data; + } + + async updateTaskAutomation( + automationId: string, + updates: Schemas.PatchedTaskAutomation, + ): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.patch( + `/api/projects/{project_id}/task_automations/{id}/`, + { + path: { project_id: teamId.toString(), id: automationId }, + body: updates, + }, + ); + return data; + } + + async deleteTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + await this.api.delete(`/api/projects/{project_id}/task_automations/{id}/`, { + path: { project_id: teamId.toString(), id: automationId }, + }); + } + + async runTaskAutomationNow( + automationId: string, + ): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.post( + `/api/projects/{project_id}/task_automations/{id}/run/`, + { + path: { project_id: teamId.toString(), id: automationId }, + body: {} as unknown as Schemas.TaskAutomation, + }, + ); + return data; + } + async sendRunCommand( taskId: string, runId: string, diff --git a/apps/code/src/renderer/features/work/components/EmptyState.tsx b/apps/code/src/renderer/features/work/components/EmptyState.tsx new file mode 100644 index 000000000..532c01c30 --- /dev/null +++ b/apps/code/src/renderer/features/work/components/EmptyState.tsx @@ -0,0 +1,77 @@ +import { ClockCounterClockwise, Plus } from "@phosphor-icons/react"; +import { Box, Button, Flex, Text } from "@radix-ui/themes"; +import { EXAMPLE_PROMPTS } from "../data/examplePrompts"; +import type { PendingCreateDraft } from "../stores/workStore"; + +interface EmptyStateProps { + onCreate: (initial?: PendingCreateDraft) => void; +} + +export function EmptyState({ onCreate }: EmptyStateProps) { + return ( + + + + + + + No scheduled tasks yet + + + Set up a task that runs on its own schedule — describe what you want + done in plain English and pick how often. + + + + + + Start from an example + + {EXAMPLE_PROMPTS.map((example) => { + const Icon = example.icon; + return ( + + ); + })} + + + + + ); +} diff --git a/apps/code/src/renderer/features/work/components/ScheduleField.tsx b/apps/code/src/renderer/features/work/components/ScheduleField.tsx new file mode 100644 index 000000000..0d743d71e --- /dev/null +++ b/apps/code/src/renderer/features/work/components/ScheduleField.tsx @@ -0,0 +1,83 @@ +import { Check, WarningCircle } from "@phosphor-icons/react"; +import { Flex, Text, TextField } from "@radix-ui/themes"; +import { parseSchedule } from "../utils/parseSchedule"; + +interface ScheduleFieldProps { + value: string; + onChange: (next: string) => void; +} + +const QUICK_FILLS = [ + "Daily at 9am", + "Weekdays at 9am", + "Mondays at 9am", + "Every hour", + "1st of month at 9am", +]; + +export function ScheduleField({ value, onChange }: ScheduleFieldProps) { + const trimmed = value.trim(); + const parsed = trimmed ? parseSchedule(trimmed) : null; + const hasValue = trimmed.length > 0; + const isValid = parsed !== null; + + return ( + + + Schedule + + + onChange(e.target.value)} + /> + + {hasValue && isValid && ( + + + → {parsed.description} + + )} + {hasValue && !isValid && ( + + + + Couldn't understand that — try "every Tuesday at 5pm", "daily at + 9am", "weekdays at 9am", or a cron expression. + + + )} + + + + Quick fills + + + {QUICK_FILLS.map((label) => { + const isActive = trimmed.toLowerCase() === label.toLowerCase(); + return ( + + ); + })} + + + + ); +} diff --git a/apps/code/src/renderer/features/work/components/ScheduledTaskEditor.tsx b/apps/code/src/renderer/features/work/components/ScheduledTaskEditor.tsx new file mode 100644 index 000000000..7877f6037 --- /dev/null +++ b/apps/code/src/renderer/features/work/components/ScheduledTaskEditor.tsx @@ -0,0 +1,402 @@ +import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; +import { + ArrowLeft, + ArrowSquareOut, + ClockCounterClockwise, + FloppyDisk, + Play, + Trash, +} from "@phosphor-icons/react"; +import { + Box, + Button, + Callout, + Flex, + ScrollArea, + Switch, + Text, + TextArea, + TextField, +} from "@radix-ui/themes"; +import type { Schemas } from "@renderer/api/generated"; +import { useNavigationStore } from "@stores/navigationStore"; +import { formatRelativeTimeLong } from "@utils/time"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useOpenLastRun } from "../hooks/useOpenLastRun"; +import { + useCreateScheduledTask, + useDeleteScheduledTask, + useRunScheduledTaskNow, + useScheduledTasks, + useUpdateScheduledTask, +} from "../hooks/useScheduledTasks"; +import { useWorkStore } from "../stores/workStore"; +import { describeCron, parseSchedule } from "../utils/parseSchedule"; +import { decodePrompt, encodePrompt } from "../utils/sourcesPrompt"; +import { detectTimezone } from "../utils/timezone"; +import { ScheduledTaskStatusBadge } from "./ScheduledTaskStatusBadge"; +import { ScheduleField } from "./ScheduleField"; +import { SourcesPicker } from "./SourcesPicker"; + +interface ScheduledTaskEditorProps { + editingId: string | null; +} + +interface Draft { + name: string; + promptBody: string; + sources: string[]; + scheduleText: string; + enabled: boolean; +} + +function toDraft(automation: Schemas.TaskAutomation | null): Draft { + if (!automation) { + return { + name: "", + promptBody: "", + sources: [], + scheduleText: "Daily at 9am", + enabled: true, + }; + } + const { sources, body } = decodePrompt(automation.prompt); + return { + name: automation.name, + promptBody: body, + sources, + scheduleText: describeCron(automation.cron_expression), + enabled: automation.enabled ?? true, + }; +} + +export function ScheduledTaskEditor({ editingId }: ScheduledTaskEditorProps) { + const showList = useNavigationStore((s) => s.navigateToWorkScheduledList); + const consumePendingCreateDraft = useWorkStore( + (s) => s.consumePendingCreateDraft, + ); + const { data: automations } = useScheduledTasks(); + + const existing = useMemo( + () => automations?.find((a) => a.id === editingId) ?? null, + [automations, editingId], + ); + + const [draft, setDraft] = useState(() => { + const base = toDraft(existing); + if (!existing) { + const seeded = consumePendingCreateDraft(); + if (seeded) { + return { + ...base, + name: seeded.name ?? base.name, + promptBody: seeded.prompt ?? base.promptBody, + }; + } + } + return base; + }); + + // Sync the draft when the editor is pointed at a different existing automation. + useEffect(() => { + if (existing) setDraft(toDraft(existing)); + // The id is what should trigger a reset — other field changes from polling + // shouldn't clobber user edits. + }, [existing?.id, existing]); + + const createScheduledTask = useCreateScheduledTask(); + const updateScheduledTask = useUpdateScheduledTask(); + const deleteScheduledTask = useDeleteScheduledTask(); + const runScheduledTaskNow = useRunScheduledTaskNow(); + const { openLastRun, isOpening } = useOpenLastRun(); + + const isEditing = editingId !== null && existing !== null; + const isSaving = + createScheduledTask.isPending || updateScheduledTask.isPending; + + const nameTrimmed = draft.name.trim(); + const promptTrimmed = draft.promptBody.trim(); + const parsedSchedule = parseSchedule(draft.scheduleText); + const missingFields = !nameTrimmed || !promptTrimmed || !parsedSchedule; + const canSave = !missingFields && !isSaving; + + const headerContent = useMemo( + () => ( + + + + {isEditing ? existing.name || "Scheduled task" : "New scheduled task"} + + + ), + [isEditing, existing], + ); + useSetHeaderContent(headerContent); + + const handleSave = async () => { + if (!canSave || !parsedSchedule) return; + const promptToSend = encodePrompt(promptTrimmed, draft.sources); + + try { + if (isEditing) { + await updateScheduledTask.mutateAsync({ + id: existing.id, + updates: { + name: nameTrimmed, + prompt: promptToSend, + cron_expression: parsedSchedule.cron, + enabled: draft.enabled, + }, + }); + toast.success("Scheduled task updated"); + } else { + await createScheduledTask.mutateAsync({ + name: nameTrimmed, + prompt: promptToSend, + cron_expression: parsedSchedule.cron, + // Work-mode tasks aren't repo-scoped, but the backend currently + // requires a non-blank `repository`. Send a sentinel until the + // backend makes the field nullable; the runtime ignores it for + // PostHog-data skills. + repository: "posthog-work", + timezone: detectTimezone(), + enabled: draft.enabled, + }); + toast.success("Scheduled task created"); + } + showList(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to save scheduled task", + ); + } + }; + + const handleDelete = async () => { + if (!isEditing) return; + if ( + !window.confirm( + `Delete "${existing.name || "this scheduled task"}"? This cannot be undone.`, + ) + ) { + return; + } + try { + await deleteScheduledTask.mutateAsync(existing.id); + toast.success("Scheduled task deleted"); + showList(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to delete scheduled task", + ); + } + }; + + const handleRunNow = async () => { + if (!isEditing) return; + try { + await runScheduledTaskNow.mutateAsync(existing.id); + toast.success("Running now — opening the task to follow along"); + // After the run is triggered the automation row gets new last_task_id / + // last_task_run_id values on the next refetch. We don't deep-link + // immediately because the task may take a moment to appear in cache. + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to run scheduled task", + ); + } + }; + + return ( + + + + + + {isEditing ? "Edit scheduled task" : "New scheduled task"} + + + + {isEditing && ( + <> + + + + )} + + + + + + + + + + Name + + + setDraft((d) => ({ ...d, name: e.target.value })) + } + /> + + + + + What should it do? + +