Skip to content
Merged
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
104 changes: 104 additions & 0 deletions apps/code/src/renderer/api/posthogClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
}),
);
});
});
});
71 changes: 71 additions & 0 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,77 @@ export class PostHogAPIClient {
});
}

async listTaskAutomations(): Promise<Schemas.TaskAutomation[]> {
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<Schemas.TaskAutomation> {
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<Schemas.TaskAutomation> {
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<void> {
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<Schemas.TaskAutomation> {
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,
Expand Down
77 changes: 77 additions & 0 deletions apps/code/src/renderer/features/work/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex
align="center"
justify="center"
direction="column"
gap="5"
className="py-12"
>
<Box className="rounded-(--radius-3) border border-(--gray-6) border-dashed p-5">
<ClockCounterClockwise size={28} className="text-(--gray-8)" />
</Box>
<Flex direction="column" align="center" gap="2" className="max-w-md">
<Text size="3" weight="medium" className="text-(--gray-12)">
No scheduled tasks yet
</Text>
<Text size="2" align="center" className="text-(--gray-11)">
Set up a task that runs on its own schedule — describe what you want
done in plain English and pick how often.
</Text>
</Flex>

<Flex direction="column" gap="2" className="w-full max-w-md">
<Text
size="1"
weight="medium"
className="text-(--gray-10) uppercase tracking-wider"
>
Start from an example
</Text>
{EXAMPLE_PROMPTS.map((example) => {
const Icon = example.icon;
return (
<button
key={example.id}
type="button"
onClick={() =>
onCreate({ name: example.name, prompt: example.prompt })
}
className="flex w-full cursor-pointer items-start gap-3 rounded-(--radius-2) border border-(--gray-5) bg-(--gray-2) px-3 py-3 text-left transition-colors hover:bg-(--gray-3)"
>
<Box className="mt-[2px] shrink-0 text-(--accent-10)">
<Icon size={16} weight="duotone" />
</Box>
<Flex direction="column" gap="1" className="min-w-0">
<Text
size="2"
weight="medium"
className="truncate text-(--gray-12)"
>
{example.name}
</Text>
<Text size="1" className="text-(--gray-11)">
{example.description}
</Text>
</Flex>
</button>
);
})}
</Flex>

<Button size="2" variant="soft" onClick={() => onCreate()}>
<Plus size={14} />
Start from scratch
</Button>
</Flex>
);
}
83 changes: 83 additions & 0 deletions apps/code/src/renderer/features/work/components/ScheduleField.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex direction="column" gap="2">
<Text size="1" weight="medium" className="text-(--gray-11)">
Schedule
</Text>

<TextField.Root
size="2"
placeholder="e.g. every Tuesday at 5pm"
value={value}
onChange={(e) => onChange(e.target.value)}
/>

{hasValue && isValid && (
<Flex align="center" gap="2" className="text-(--green-11)">
<Check size={12} />
<Text size="1">→ {parsed.description}</Text>
</Flex>
)}
{hasValue && !isValid && (
<Flex align="center" gap="2" className="text-(--amber-11)">
<WarningCircle size={12} />
<Text size="1">
Couldn't understand that — try "every Tuesday at 5pm", "daily at
9am", "weekdays at 9am", or a cron expression.
</Text>
</Flex>
)}

<Flex direction="column" gap="1" className="pt-1">
<Text
size="1"
weight="medium"
className="text-(--gray-10) uppercase tracking-wider"
>
Quick fills
</Text>
<Flex gap="1" wrap="wrap">
{QUICK_FILLS.map((label) => {
const isActive = trimmed.toLowerCase() === label.toLowerCase();
return (
<button
key={label}
type="button"
onClick={() => onChange(label)}
className={`cursor-pointer rounded-full border px-3 py-1 text-[12px] transition-colors ${
isActive
? "border-(--accent-7) bg-(--accent-3) text-(--gray-12)"
: "border-(--gray-5) bg-(--gray-2) text-(--gray-11) hover:bg-(--gray-3) hover:text-(--gray-12)"
}`}
>
{label}
</button>
);
})}
</Flex>
</Flex>
</Flex>
);
}
Loading
Loading