diff --git a/__tests__/api/automation-handlers.test.ts b/__tests__/api/automation-handlers.test.ts
new file mode 100644
index 0000000000..34a1592d48
--- /dev/null
+++ b/__tests__/api/automation-handlers.test.ts
@@ -0,0 +1,123 @@
+import { http, HttpResponse } from "msw";
+import { setupServer } from "msw/node";
+import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
+import {
+ AUTOMATION_HANDLERS,
+ resetAutomationMockData,
+} from "#/mocks/automation-handlers";
+import { MOCK_AUTOMATIONS_RESPONSE } from "#/mocks/automations.mock";
+
+const server = setupServer(...AUTOMATION_HANDLERS);
+
+describe("Automation MSW Handlers", () => {
+ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+ afterAll(() => server.close());
+ afterEach(() => {
+ server.resetHandlers();
+ resetAutomationMockData();
+ });
+
+ describe("GET /api/automation/v1", () => {
+ it("returns paginated automations list", async () => {
+ const res = await fetch("/api/automation/v1?limit=10&offset=0");
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data.automations).toHaveLength(5);
+ expect(data.total).toBe(5);
+ });
+
+ it("respects pagination parameters", async () => {
+ const res = await fetch("/api/automation/v1?limit=2&offset=2");
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data.automations).toHaveLength(2);
+ expect(data.automations[0].name).toBe("Docs Sync on Push");
+ });
+ });
+
+ describe("GET /api/automation/v1/:id", () => {
+ it("returns a single automation by id", async () => {
+ const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id;
+ const res = await fetch(`/api/automation/v1/${id}`);
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data.id).toBe(id);
+ expect(data.name).toBe("PR Triage Digest");
+ });
+
+ it("returns 404 for non-existent automation", async () => {
+ const res = await fetch("/api/automation/v1/non-existent-id");
+
+ expect(res.status).toBe(404);
+ });
+ });
+
+ describe("PATCH /api/automation/v1/:id", () => {
+ it("updates an automation", async () => {
+ const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id;
+ const res = await fetch(`/api/automation/v1/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: false }),
+ });
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data.enabled).toBe(false);
+ });
+
+ it("returns 404 for non-existent automation", async () => {
+ const res = await fetch("/api/automation/v1/non-existent-id", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: false }),
+ });
+
+ expect(res.status).toBe(404);
+ });
+ });
+
+ describe("DELETE /api/automation/v1/:id", () => {
+ it("deletes an automation", async () => {
+ const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id;
+ const res = await fetch(`/api/automation/v1/${id}`, {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(204);
+
+ // Verify it's actually deleted
+ const getRes = await fetch(`/api/automation/v1/${id}`);
+ expect(getRes.status).toBe(404);
+ });
+
+ it("returns 404 for non-existent automation", async () => {
+ const res = await fetch("/api/automation/v1/non-existent-id", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(404);
+ });
+ });
+
+ describe("GET /api/automation/v1/:id/runs", () => {
+ it("returns automation runs", async () => {
+ const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id;
+ const res = await fetch(`/api/automation/v1/${id}/runs?limit=5&offset=0`);
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data.runs).toBeInstanceOf(Array);
+ expect(data.total).toBeGreaterThanOrEqual(0);
+ });
+
+ it("returns 404 for non-existent automation", async () => {
+ const res = await fetch("/api/automation/v1/non-existent-id/runs");
+
+ expect(res.status).toBe(404);
+ });
+ });
+});
diff --git a/__tests__/api/automation-service.test.ts b/__tests__/api/automation-service.test.ts
new file mode 100644
index 0000000000..9a64aae8af
--- /dev/null
+++ b/__tests__/api/automation-service.test.ts
@@ -0,0 +1,203 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type {
+ Automation,
+ AutomationsResponse,
+ AutomationRunsResponse,
+} from "#/types/automation";
+
+// Use vi.hoisted to define mocks that will be available during vi.mock hoisting
+const { mockGet, mockPatch, mockDelete } = vi.hoisted(() => ({
+ mockGet: vi.fn(),
+ mockPatch: vi.fn(),
+ mockDelete: vi.fn(),
+}));
+
+vi.mock("axios", () => ({
+ default: {
+ create: () => ({
+ get: mockGet,
+ patch: mockPatch,
+ delete: mockDelete,
+ interceptors: {
+ request: {
+ use: vi.fn(),
+ },
+ },
+ }),
+ },
+}));
+
+// Import after mocking
+import AutomationService from "#/api/automation-service/automation-service.api";
+
+const mockAutomation: Automation = {
+ id: "1",
+ name: "Test Automation",
+ prompt: "A test automation",
+ trigger: { type: "schedule", schedule_human: "Daily at 09:00" },
+ enabled: true,
+ repository: "acme/repo",
+ model: "Claude Opus",
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-02T00:00:00Z",
+};
+
+describe("AutomationService", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("listAutomations", () => {
+ it("fetches paginated automations list with params object", async () => {
+ const response: AutomationsResponse = {
+ automations: [mockAutomation],
+ total: 1,
+ };
+ mockGet.mockResolvedValue({ data: response });
+
+ const result = await AutomationService.listAutomations({
+ limit: 10,
+ offset: 5,
+ });
+
+ expect(mockGet).toHaveBeenCalledWith("/api/automation/v1", {
+ params: { limit: 10, offset: 5 },
+ });
+ expect(result).toEqual(response);
+ });
+
+ it("uses default params when none provided", async () => {
+ const response: AutomationsResponse = {
+ automations: [],
+ total: 0,
+ };
+ mockGet.mockResolvedValue({ data: response });
+
+ await AutomationService.listAutomations();
+
+ expect(mockGet).toHaveBeenCalledWith("/api/automation/v1", {
+ params: { limit: 50, offset: 0 },
+ });
+ });
+ });
+
+ describe("getAutomations", () => {
+ it("delegates to listAutomations", async () => {
+ const response: AutomationsResponse = {
+ automations: [mockAutomation],
+ total: 1,
+ };
+ vi.spyOn(AutomationService, "listAutomations").mockResolvedValue(
+ response,
+ );
+
+ const result = await AutomationService.getAutomations(10, 5);
+
+ expect(AutomationService.listAutomations).toHaveBeenCalledWith({
+ limit: 10,
+ offset: 5,
+ });
+ expect(result).toEqual(response);
+ });
+ });
+
+ describe("getAutomation", () => {
+ it("fetches a single automation by id", async () => {
+ mockGet.mockResolvedValue({
+ data: mockAutomation,
+ });
+
+ const result = await AutomationService.getAutomation("1");
+
+ expect(mockGet).toHaveBeenCalledWith("/api/automation/v1/1");
+ expect(result).toEqual(mockAutomation);
+ });
+ });
+
+ describe("updateAutomation", () => {
+ it("patches an automation with the provided body", async () => {
+ const updated = { ...mockAutomation, name: "Updated Name" };
+ mockPatch.mockResolvedValue({ data: updated });
+
+ const result = await AutomationService.updateAutomation("1", {
+ name: "Updated Name",
+ });
+
+ expect(mockPatch).toHaveBeenCalledWith("/api/automation/v1/1", {
+ name: "Updated Name",
+ });
+ expect(result).toEqual(updated);
+ });
+ });
+
+ describe("deleteAutomation", () => {
+ it("deletes an automation by id", async () => {
+ mockDelete.mockResolvedValue({});
+
+ await AutomationService.deleteAutomation("1");
+
+ expect(mockDelete).toHaveBeenCalledWith("/api/automation/v1/1");
+ });
+ });
+
+ describe("listAutomationRuns", () => {
+ it("fetches runs with params object", async () => {
+ const response: AutomationRunsResponse = { runs: [], total: 0 };
+ mockGet.mockResolvedValue({ data: response });
+
+ const result = await AutomationService.listAutomationRuns("1", {
+ limit: 20,
+ offset: 10,
+ });
+
+ expect(mockGet).toHaveBeenCalledWith("/api/automation/v1/1/runs", {
+ params: { limit: 20, offset: 10 },
+ });
+ expect(result).toEqual(response);
+ });
+
+ it("uses default params when none provided", async () => {
+ const response: AutomationRunsResponse = { runs: [], total: 0 };
+ mockGet.mockResolvedValue({ data: response });
+
+ await AutomationService.listAutomationRuns("1");
+
+ expect(mockGet).toHaveBeenCalledWith("/api/automation/v1/1/runs", {
+ params: { limit: 50, offset: 0 },
+ });
+ });
+ });
+
+ describe("getAutomationRuns", () => {
+ it("delegates to listAutomationRuns", async () => {
+ const response: AutomationRunsResponse = { runs: [], total: 0 };
+ vi.spyOn(AutomationService, "listAutomationRuns").mockResolvedValue(
+ response,
+ );
+
+ const result = await AutomationService.getAutomationRuns("1", 25, 5);
+
+ expect(AutomationService.listAutomationRuns).toHaveBeenCalledWith("1", {
+ limit: 25,
+ offset: 5,
+ });
+ expect(result).toEqual(response);
+ });
+ });
+
+ describe("toggleAutomation", () => {
+ it("delegates to updateAutomation with enabled field", async () => {
+ const toggled = { ...mockAutomation, enabled: false };
+ vi.spyOn(AutomationService, "updateAutomation").mockResolvedValue(
+ toggled,
+ );
+
+ const result = await AutomationService.toggleAutomation("1", false);
+
+ expect(AutomationService.updateAutomation).toHaveBeenCalledWith("1", {
+ enabled: false,
+ });
+ expect(result).toEqual(toggled);
+ });
+ });
+});
diff --git a/__tests__/components/automations/detail/not-found-state.test.tsx b/__tests__/components/automations/detail/not-found-state.test.tsx
new file mode 100644
index 0000000000..bb9e4d20ca
--- /dev/null
+++ b/__tests__/components/automations/detail/not-found-state.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { NotFoundState } from "#/components/features/automations/detail/not-found-state";
+
+describe("NotFoundState", () => {
+ it("renders not found state with link back to automations list", () => {
+ const { container } = render();
+
+ // Check for the main container
+ expect(container.querySelector("div")).toBeInTheDocument();
+ // Check for the link to go back
+ const link = screen.getByRole("link");
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute("href", "/automations");
+ });
+});
diff --git a/__tests__/components/automations/detail/run-status-badge.test.tsx b/__tests__/components/automations/detail/run-status-badge.test.tsx
new file mode 100644
index 0000000000..f2d8569c90
--- /dev/null
+++ b/__tests__/components/automations/detail/run-status-badge.test.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { RunStatusBadge } from "#/components/features/automations/detail/run-status-badge";
+import { AutomationRunStatus } from "#/types/automation";
+
+describe("RunStatusBadge", () => {
+ it("renders badge with correct style for completed status", () => {
+ const { container } = render(
+ ,
+ );
+ const badge = container.querySelector("span");
+ expect(badge).toBeInTheDocument();
+ expect(badge?.className).toContain("text-success");
+ });
+
+ it("renders badge with correct style for failed status", () => {
+ const { container } = render(
+ ,
+ );
+ const badge = container.querySelector("span");
+ expect(badge).toBeInTheDocument();
+ expect(badge?.className).toContain("text-red-400");
+ });
+
+ it("renders badge with correct style for pending status", () => {
+ const { container } = render(
+ ,
+ );
+ const badge = container.querySelector("span");
+ expect(badge).toBeInTheDocument();
+ expect(badge?.className).toContain("text-neutral-400");
+ });
+
+ it("renders badge with correct style for running status", () => {
+ const { container } = render(
+ ,
+ );
+ const badge = container.querySelector("span");
+ expect(badge).toBeInTheDocument();
+ expect(badge?.className).toContain("text-neutral-400");
+ });
+});
diff --git a/__tests__/components/automations/detail/section-card.test.tsx b/__tests__/components/automations/detail/section-card.test.tsx
new file mode 100644
index 0000000000..087da2e677
--- /dev/null
+++ b/__tests__/components/automations/detail/section-card.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { SectionCard } from "#/components/features/automations/detail/section-card";
+
+describe("SectionCard", () => {
+ it("renders the title and children content", () => {
+ render(
+ } title="Test Section">
+
{String("Section content")}
+ ,
+ );
+
+ expect(screen.getByText("Test Section")).toBeInTheDocument();
+ expect(screen.getByText("Section content")).toBeInTheDocument();
+ expect(screen.getByTestId("icon")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/automations/error-state.test.tsx b/__tests__/components/automations/error-state.test.tsx
new file mode 100644
index 0000000000..c5b22ef371
--- /dev/null
+++ b/__tests__/components/automations/error-state.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { vi, describe, it, expect } from "vitest";
+import { ErrorState } from "#/components/features/automations/error-state";
+
+describe("ErrorState", () => {
+ it("renders error icon and retry button", () => {
+ const { container } = render();
+
+ // Check for the error icon (ExclamationCircleIcon)
+ expect(container.querySelector("svg")).toBeInTheDocument();
+ // Check for the retry button
+ expect(screen.getByRole("button")).toBeInTheDocument();
+ });
+
+ it("calls onRetry when retry button is clicked", async () => {
+ const onRetry = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button"));
+
+ expect(onRetry).toHaveBeenCalledOnce();
+ });
+});
diff --git a/__tests__/components/automations/metadata-chip.test.tsx b/__tests__/components/automations/metadata-chip.test.tsx
new file mode 100644
index 0000000000..8b0b3501c8
--- /dev/null
+++ b/__tests__/components/automations/metadata-chip.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { MetadataChip } from "#/components/features/automations/metadata-chip";
+
+describe("MetadataChip", () => {
+ it("renders the label and icon", () => {
+ render(
+ }
+ label="acme/frontend-app"
+ />,
+ );
+
+ expect(screen.getByText("acme/frontend-app")).toBeInTheDocument();
+ expect(screen.getByTestId("chip-icon")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/automations/search-input.test.tsx b/__tests__/components/automations/search-input.test.tsx
new file mode 100644
index 0000000000..adc66227b2
--- /dev/null
+++ b/__tests__/components/automations/search-input.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { vi, describe, it, expect } from "vitest";
+import { SearchInput } from "#/components/features/automations/search-input";
+
+describe("SearchInput", () => {
+ it("calls onChange when user types", async () => {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "test");
+
+ expect(onChange).toHaveBeenCalled();
+ expect(onChange).toHaveBeenLastCalledWith("t");
+ });
+
+ it("displays the current value", () => {
+ render();
+
+ expect(screen.getByRole("textbox")).toHaveValue("security");
+ });
+});
diff --git a/__tests__/components/automations/toggle-switch.test.tsx b/__tests__/components/automations/toggle-switch.test.tsx
new file mode 100644
index 0000000000..a86e0aeddf
--- /dev/null
+++ b/__tests__/components/automations/toggle-switch.test.tsx
@@ -0,0 +1,38 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { vi, describe, it, expect } from "vitest";
+import { ToggleSwitch } from "#/components/features/automations/toggle-switch";
+
+describe("ToggleSwitch", () => {
+ it("calls onToggle when clicked", async () => {
+ const onToggle = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("switch"));
+
+ expect(onToggle).toHaveBeenCalledOnce();
+ });
+
+ it("reflects enabled state via aria-checked", () => {
+ render();
+
+ expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("stops event propagation on click", async () => {
+ const parentClick = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+
,
+ );
+
+ await user.click(screen.getByRole("switch"));
+
+ expect(parentClick).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/scripts/ingress.test.ts b/__tests__/scripts/ingress.test.ts
index 2cda7a6ce1..317e09556e 100644
--- a/__tests__/scripts/ingress.test.ts
+++ b/__tests__/scripts/ingress.test.ts
@@ -110,9 +110,10 @@ describe("ingress proxy functionality", () => {
let backend1: Server;
let backend2: Server;
let ingressProcess: ChildProcess;
- const backend1Port = 19001;
- const backend2Port = 19002;
- const ingressPort = 19000;
+ // Use ports in 29000 range to avoid conflicts with VS Code server (19000)
+ const backend1Port = 29001;
+ const backend2Port = 29002;
+ const ingressPort = 29000;
beforeAll(async () => {
// Create mock backend 1
@@ -198,7 +199,7 @@ describe("ingress proxy functionality", () => {
it("returns 502 when backend is unavailable", async () => {
// Start a fresh ingress pointing to a non-existent backend
- const badIngressPort = 19003;
+ const badIngressPort = 29003;
const badIngress = spawn(
process.execPath,
[
diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs
index d14b8358aa..d2291da23a 100644
--- a/scripts/dev-with-automation.mjs
+++ b/scripts/dev-with-automation.mjs
@@ -56,6 +56,8 @@ const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation";
const DEFAULT_AUTOMATION_GIT_REF = "main";
const DEFAULT_BACKEND_PORT = 18000;
const DEFAULT_AUTOMATION_PORT = 18001;
+// Default local API key for automation backend auth (matches agent-server pattern)
+const DEFAULT_LOCAL_API_KEY = "openhands-local-api-key";
// ═══════════════════════════════════════════════════════════════════════════
// Terminal Styling
@@ -168,12 +170,12 @@ function buildAutomationCommand(env = process.env) {
// Build git URL with ref
const gitUrl = `git+${repoUrl}@${gitRef}`;
+ // Use --refresh to ensure latest commit is fetched for git refs
+ const args = ["--refresh", "--from", gitUrl, "uvicorn", "automation.app:app"];
+
return {
command: "uvx",
- args: [
- "--from", gitUrl,
- "uvicorn", "automation.app:app",
- ],
+ args,
source: `git (${gitRef})`,
};
}
@@ -193,6 +195,9 @@ function buildConfig(args, env = process.env) {
const vitePort = 3001;
const vscodePort = backendPort + 1000;
+ // Local API key for automation backend auth
+ const localApiKey = env.AUTOMATION_LOCAL_API_KEY || DEFAULT_LOCAL_API_KEY;
+
return {
// Ingress port (main entry point)
ingressPort,
@@ -209,6 +214,9 @@ function buildConfig(args, env = process.env) {
// Data directories (same as dev-safe.mjs)
stateDir: join(homedir(), ".openhands", "agent-canvas"),
+ // Auth
+ localApiKey,
+
verbose: args.verbose,
};
}
@@ -379,9 +387,12 @@ function startAutomationBackend(config) {
env: {
AUTOMATION_AGENT_SERVER_URL: `http://localhost:${config.agentServerPort}`,
AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(config.stateDir, "automations.db")}`,
- AUTOMATION_BASE_URL: `http://localhost:3001`,
+ AUTOMATION_BASE_URL: `http://localhost:${config.ingressPort}`,
AUTOMATION_WORKSPACE_BASE: join(config.stateDir, "workspaces"),
- AUTOMATION_AUTH_DISABLED: "true",
+ // Local API key for self-hosted auth (no cloud API needed)
+ AUTOMATION_LOCAL_API_KEY: config.localApiKey,
+ // CORS: allow localhost origins for dev
+ AUTOMATION_CORS_ORIGINS: `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`,
FILE_STORE: "local",
LOCAL_STORAGE_PATH: join(config.stateDir, "storage"),
OPENHANDS_SUPPRESS_BANNER: "1",
@@ -462,6 +473,8 @@ function startVite(config) {
VITE_BACKEND_BASE_URL: `http://127.0.0.1:${config.ingressPort}`,
VITE_WORKING_DIR: join(config.stateDir, "workspaces"),
VITE_FRONTEND_PORT: config.vitePort.toString(),
+ // Automation API key for frontend to authenticate with automation backend
+ VITE_AUTOMATION_API_KEY: config.localApiKey,
},
color: c.magenta,
});
diff --git a/src/api/automation-service/automation-service.api.ts b/src/api/automation-service/automation-service.api.ts
new file mode 100644
index 0000000000..cfda9c262a
--- /dev/null
+++ b/src/api/automation-service/automation-service.api.ts
@@ -0,0 +1,97 @@
+import axios from "axios";
+import type {
+ Automation,
+ AutomationsResponse,
+ AutomationRunsResponse,
+} from "#/types/automation";
+import { getAgentServerBaseUrl } from "../agent-server-config";
+
+const AUTOMATION_BASE_PATH = "/api/automation";
+
+// Create axios instance for automation API with Bearer auth
+const automationAxios = axios.create({
+ baseURL: getAgentServerBaseUrl(),
+});
+
+automationAxios.interceptors.request.use((config) => {
+ const apiKey = import.meta.env.VITE_AUTOMATION_API_KEY?.trim();
+ if (apiKey) {
+ config.headers.set("Authorization", `Bearer ${apiKey}`);
+ }
+ return config;
+});
+
+class AutomationService {
+ static async listAutomations(
+ params: { limit?: number; offset?: number } = {},
+ ): Promise {
+ const { limit = 50, offset = 0 } = params;
+ const { data } = await automationAxios.get(
+ `${AUTOMATION_BASE_PATH}/v1`,
+ {
+ params: { limit, offset },
+ },
+ );
+ return data;
+ }
+
+ static async getAutomations(
+ limit = 50,
+ offset = 0,
+ ): Promise {
+ return AutomationService.listAutomations({ limit, offset });
+ }
+
+ static async getAutomation(id: string): Promise {
+ const { data } = await automationAxios.get(
+ `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
+ );
+ return data;
+ }
+
+ static async updateAutomation(
+ id: string,
+ body: Partial,
+ ): Promise {
+ const { data } = await automationAxios.patch(
+ `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
+ body,
+ );
+ return data;
+ }
+
+ static async deleteAutomation(id: string): Promise {
+ await automationAxios.delete(
+ `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
+ );
+ }
+
+ static async listAutomationRuns(
+ id: string,
+ params: { limit?: number; offset?: number } = {},
+ ): Promise {
+ const { limit = 50, offset = 0 } = params;
+ const { data } = await automationAxios.get(
+ `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}/runs`,
+ { params: { limit, offset } },
+ );
+ return data;
+ }
+
+ static async getAutomationRuns(
+ id: string,
+ limit = 50,
+ offset = 0,
+ ): Promise {
+ return AutomationService.listAutomationRuns(id, { limit, offset });
+ }
+
+ static async toggleAutomation(
+ id: string,
+ enabled: boolean,
+ ): Promise {
+ return AutomationService.updateAutomation(id, { enabled });
+ }
+}
+
+export default AutomationService;
diff --git a/src/components/features/automations/automation-card-skeleton.tsx b/src/components/features/automations/automation-card-skeleton.tsx
new file mode 100644
index 0000000000..7b29d8833f
--- /dev/null
+++ b/src/components/features/automations/automation-card-skeleton.tsx
@@ -0,0 +1,19 @@
+export function AutomationCardSkeleton() {
+ return (
+
+ );
+}
diff --git a/src/components/features/automations/automation-card.tsx b/src/components/features/automations/automation-card.tsx
new file mode 100644
index 0000000000..416000e794
--- /dev/null
+++ b/src/components/features/automations/automation-card.tsx
@@ -0,0 +1,106 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import type { Automation } from "#/types/automation";
+import { ToggleSwitch } from "./toggle-switch";
+import { MetadataChip } from "./metadata-chip";
+import { KebabMenu } from "./kebab-menu";
+import type { KebabMenuItem } from "./kebab-menu";
+import { useHasPermission } from "#/hooks/use-has-permission";
+import { useNavigation } from "#/context/navigation-context";
+import FolderIcon from "#/icons/folder.svg?react";
+import ClockIcon from "#/icons/clock.svg?react";
+import SparkleIcon from "#/icons/sparkle.svg?react";
+import PowerIcon from "#/icons/power.svg?react";
+import TrashIcon from "#/icons/trash.svg?react";
+
+interface AutomationCardProps {
+ automation: Automation;
+ onToggle: (id: string, enabled: boolean) => void;
+ onDelete: (id: string) => void;
+}
+
+export function AutomationCard({
+ automation,
+ onToggle,
+ onDelete,
+}: AutomationCardProps) {
+ const { navigate } = useNavigation();
+ const { t } = useTranslation("openhands");
+ const canManage = useHasPermission("manage_automations");
+
+ const scheduleLabel =
+ automation.trigger.schedule_human || automation.trigger.type;
+
+ const menuItems: KebabMenuItem[] = [
+ {
+ label: automation.enabled
+ ? t(I18nKey.AUTOMATIONS$TURN_OFF)
+ : t(I18nKey.AUTOMATIONS$TURN_ON),
+ icon: ,
+ onClick: () => onToggle(automation.id, automation.enabled),
+ },
+ {
+ label: t(I18nKey.AUTOMATIONS$DELETE),
+ icon: ,
+ onClick: () => onDelete(automation.id),
+ variant: "danger",
+ },
+ ];
+
+ const handleCardClick = () => {
+ navigate?.(`/automations/${automation.id}`);
+ };
+
+ return (
+ {
+ if (e.key === "Enter") handleCardClick();
+ }}
+ className="cursor-pointer rounded-2xl border border-neutral-700 bg-neutral-800 p-5 transition-colors hover:border-neutral-600"
+ >
+
+
+
+ {automation.name}
+
+
+ {automation.prompt}
+
+
+
+
+ {canManage && (
+ onToggle(automation.id, automation.enabled)}
+ />
+ )}
+ {canManage && }
+
+
+
+
+ {automation.repository && (
+ }
+ label={automation.repository}
+ />
+ )}
+ }
+ label={scheduleLabel}
+ />
+ {automation.model && (
+ }
+ label={automation.model}
+ />
+ )}
+
+
+ );
+}
diff --git a/src/components/features/automations/automation-group.tsx b/src/components/features/automations/automation-group.tsx
new file mode 100644
index 0000000000..70e0251dfa
--- /dev/null
+++ b/src/components/features/automations/automation-group.tsx
@@ -0,0 +1,40 @@
+import type { Automation } from "#/types/automation";
+import { AutomationCard } from "./automation-card";
+import { StatusBadge } from "./status-badge";
+
+interface AutomationGroupProps {
+ title: string;
+ count: number;
+ automations: Automation[];
+ onToggle: (id: string, enabled: boolean) => void;
+ onDelete: (id: string) => void;
+}
+
+export function AutomationGroup({
+ title,
+ count,
+ automations,
+ onToggle,
+ onDelete,
+}: AutomationGroupProps) {
+ if (automations.length === 0) return null;
+
+ return (
+
+
+
{title}
+
+
+
+ {automations.map((automation) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/features/automations/create-instructions.tsx b/src/components/features/automations/create-instructions.tsx
new file mode 100644
index 0000000000..8f6aa479a8
--- /dev/null
+++ b/src/components/features/automations/create-instructions.tsx
@@ -0,0 +1,119 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import TerminalIcon from "#/icons/terminal.svg?react";
+import SparkleIcon from "#/icons/sparkle.svg?react";
+import ChevronDownIcon from "#/icons/chevron-down.svg?react";
+
+const DOCS_URL =
+ "https://docs.openhands.dev/openhands/usage/automations/overview";
+const NEW_CONVERSATION_URL = "/";
+const PLUGIN_COMMAND = "/openhands-automation create";
+const PLUGIN_INSTALL_URL =
+ "https://github.com/OpenHands/extensions#quick-start";
+
+interface CreateInstructionsProps {
+ /** If true, the instructions are collapsible and start collapsed */
+ collapsible?: boolean;
+}
+
+export function CreateInstructions({
+ collapsible = false,
+}: CreateInstructionsProps) {
+ const { t } = useTranslation("openhands");
+ const [isExpanded, setIsExpanded] = useState(!collapsible);
+
+ const content = (
+ <>
+
+ {/* Option 1: Claude Code / Codex */}
+
+
+ {/* Option 2: OpenHands Cloud conversation */}
+
+
+
+ {/* Documentation link */}
+
+
+ {t(I18nKey.AUTOMATIONS$EMPTY_LEARN_MORE)}
+
+
+ >
+ );
+
+ if (collapsible) {
+ return (
+
+
+ {isExpanded &&
{content}
}
+
+ );
+ }
+
+ return (
+
+
+ {t(I18nKey.AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE)}
+
+ {content}
+
+ );
+}
diff --git a/src/components/features/automations/delete-confirmation-modal.tsx b/src/components/features/automations/delete-confirmation-modal.tsx
new file mode 100644
index 0000000000..1cdb98f5d5
--- /dev/null
+++ b/src/components/features/automations/delete-confirmation-modal.tsx
@@ -0,0 +1,70 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import XMarkIcon from "#/icons/x-mark.svg?react";
+
+interface DeleteConfirmationModalProps {
+ automationName: string;
+ isOpen: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+export function DeleteConfirmationModal({
+ automationName,
+ isOpen,
+ onConfirm,
+ onCancel,
+}: DeleteConfirmationModalProps) {
+ const { t } = useTranslation("openhands");
+
+ if (!isOpen) return null;
+
+ return (
+
+
{
+ if (e.key === "Escape") onCancel();
+ }}
+ role="presentation"
+ />
+
+
+
+
+ {t(I18nKey.AUTOMATIONS$DELETE_CONFIRM_TITLE)}
+
+
+ {t(I18nKey.AUTOMATIONS$DELETE_CONFIRM_MESSAGE, {
+ name: automationName,
+ })}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/features/automations/detail/active-status-badge.tsx b/src/components/features/automations/detail/active-status-badge.tsx
new file mode 100644
index 0000000000..a85041eaef
--- /dev/null
+++ b/src/components/features/automations/detail/active-status-badge.tsx
@@ -0,0 +1,24 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+
+interface ActiveStatusBadgeProps {
+ active: boolean;
+}
+
+export function ActiveStatusBadge({ active }: ActiveStatusBadgeProps) {
+ const { t } = useTranslation("openhands");
+
+ return (
+
+ {active
+ ? t(I18nKey.AUTOMATIONS$DETAIL$ACTIVE)
+ : t(I18nKey.AUTOMATIONS$DETAIL$INACTIVE)}
+
+ );
+}
diff --git a/src/components/features/automations/detail/activity-log-item.tsx b/src/components/features/automations/detail/activity-log-item.tsx
new file mode 100644
index 0000000000..0fc27184ca
--- /dev/null
+++ b/src/components/features/automations/detail/activity-log-item.tsx
@@ -0,0 +1,64 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import type { AutomationRun } from "#/types/automation";
+import { RunStatusBadge } from "./run-status-badge";
+
+interface ActivityLogItemProps {
+ run: AutomationRun;
+}
+
+function formatRunTimestamp(dateStr: string, locale: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString(locale, {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+function getConversationUrl(conversationId: string): string {
+ // In agent-canvas, conversations are at /conversations/:id
+ return `/conversations/${conversationId}`;
+}
+
+export function ActivityLogItem({ run }: ActivityLogItemProps) {
+ const { t, i18n } = useTranslation("openhands");
+ const hasConversation = !!run.conversation_id;
+
+ const content = (
+ <>
+
+
+ {formatRunTimestamp(run.started_at, i18n.language)}
+
+ {!hasConversation && (
+
+ ({t(I18nKey.AUTOMATIONS$DETAIL$NO_CONVERSATION)})
+
+ )}
+
+
+ >
+ );
+
+ if (hasConversation && run.conversation_id) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/src/components/features/automations/detail/activity-log-section.tsx b/src/components/features/automations/detail/activity-log-section.tsx
new file mode 100644
index 0000000000..3be3724ac4
--- /dev/null
+++ b/src/components/features/automations/detail/activity-log-section.tsx
@@ -0,0 +1,78 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { useAutomationRuns } from "#/hooks/query/use-automation-detail";
+import ActivityIcon from "#/icons/activity.svg?react";
+import { ActivityLogItem } from "./activity-log-item";
+
+interface ActivityLogSectionProps {
+ automationId: string;
+}
+
+const PAGE_SIZE = 20;
+
+export function ActivityLogSection({ automationId }: ActivityLogSectionProps) {
+ const { t } = useTranslation("openhands");
+ const [limit, setLimit] = useState(PAGE_SIZE);
+ const { data, isLoading } = useAutomationRuns(automationId, limit, 0);
+
+ const hasMore = data ? data.total > data.runs.length : false;
+
+ return (
+
+
+
+
+
+
+ {t(I18nKey.AUTOMATIONS$DETAIL$ACTIVITY_LOG)}
+
+
+
+ {isLoading && (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {!isLoading && data && data.runs.length === 0 && (
+
+ {t(I18nKey.AUTOMATIONS$DETAIL$NO_RUNS)}
+
+ )}
+
+ {!isLoading && data && data.runs.length > 0 && (
+
+ {data.runs.map((run, index) => (
+
0 ? "border-t border-neutral-700" : ""}
+ >
+
+
+ ))}
+
+ {hasMore && (
+
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/features/automations/detail/activity-section.tsx b/src/components/features/automations/detail/activity-section.tsx
new file mode 100644
index 0000000000..f4c63784cb
--- /dev/null
+++ b/src/components/features/automations/detail/activity-section.tsx
@@ -0,0 +1,77 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import ActivityIcon from "#/icons/activity.svg?react";
+import CalendarIcon from "#/icons/calendar.svg?react";
+import ClockIcon from "#/icons/clock.svg?react";
+import { SectionCard } from "./section-card";
+import { ConfigField } from "./config-field";
+
+interface ActivitySectionProps {
+ createdAt: string;
+ lastRunAt: string | null | undefined;
+}
+
+function formatDate(dateStr: string, locale: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString(locale, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+function formatRelativeTime(
+ dateStr: string,
+ locale: string,
+ t: (key: I18nKey, options?: Record
) => string,
+): string {
+ const now = Date.now();
+ const then = new Date(dateStr).getTime();
+ const diffMs = now - then;
+ const diffMins = Math.floor(diffMs / 60_000);
+ const diffHours = Math.floor(diffMs / 3_600_000);
+ const diffDays = Math.floor(diffMs / 86_400_000);
+
+ if (diffMins < 1) return t(I18nKey.AUTOMATIONS$DETAIL$TIME_JUST_NOW);
+ if (diffMins < 60)
+ return t(I18nKey.AUTOMATIONS$DETAIL$TIME_MINUTES_AGO, { count: diffMins });
+ if (diffHours < 24)
+ return t(I18nKey.AUTOMATIONS$DETAIL$TIME_HOURS_AGO, { count: diffHours });
+ if (diffDays === 1) return t(I18nKey.AUTOMATIONS$DETAIL$TIME_YESTERDAY);
+ if (diffDays < 7)
+ return t(I18nKey.AUTOMATIONS$DETAIL$TIME_DAYS_AGO, { count: diffDays });
+ return formatDate(dateStr, locale);
+}
+
+export function ActivitySection({
+ createdAt,
+ lastRunAt,
+}: ActivitySectionProps) {
+ const { t, i18n } = useTranslation("openhands");
+ const locale = i18n.language;
+
+ return (
+ }
+ title={t(I18nKey.AUTOMATIONS$DETAIL$ACTIVITY)}
+ >
+
+ }
+ label={t(I18nKey.AUTOMATIONS$DETAIL$CREATED)}
+ >
+ {formatDate(createdAt, locale)}
+
+
+ }
+ label={t(I18nKey.AUTOMATIONS$DETAIL$LAST_RUN)}
+ >
+ {lastRunAt
+ ? formatRelativeTime(lastRunAt, locale, t)
+ : t(I18nKey.AUTOMATIONS$DETAIL$TIME_NEVER)}
+
+
+
+ );
+}
diff --git a/src/components/features/automations/detail/back-link.tsx b/src/components/features/automations/detail/back-link.tsx
new file mode 100644
index 0000000000..bce6eb2407
--- /dev/null
+++ b/src/components/features/automations/detail/back-link.tsx
@@ -0,0 +1,18 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { NavigationLink } from "#/components/shared/navigation-link";
+import ChevronLeftIcon from "#/icons/chevron-left.svg?react";
+
+export function BackLink() {
+ const { t } = useTranslation("openhands");
+
+ return (
+
+
+ {t(I18nKey.AUTOMATIONS$DETAIL$BACK_TO_LIST)}
+
+ );
+}
diff --git a/src/components/features/automations/detail/branch-badge.tsx b/src/components/features/automations/detail/branch-badge.tsx
new file mode 100644
index 0000000000..05511e2941
--- /dev/null
+++ b/src/components/features/automations/detail/branch-badge.tsx
@@ -0,0 +1,11 @@
+interface BranchBadgeProps {
+ branch: string;
+}
+
+export function BranchBadge({ branch }: BranchBadgeProps) {
+ return (
+
+ {branch}
+
+ );
+}
diff --git a/src/components/features/automations/detail/config-field.tsx b/src/components/features/automations/detail/config-field.tsx
new file mode 100644
index 0000000000..817e06aa87
--- /dev/null
+++ b/src/components/features/automations/detail/config-field.tsx
@@ -0,0 +1,17 @@
+interface ConfigFieldProps {
+ icon: React.ReactNode;
+ label: string;
+ children: React.ReactNode;
+}
+
+export function ConfigField({ icon, label, children }: ConfigFieldProps) {
+ return (
+
+
+ {icon}
+ {label}
+
+
{children}
+
+ );
+}
diff --git a/src/components/features/automations/detail/configuration-section.tsx b/src/components/features/automations/detail/configuration-section.tsx
new file mode 100644
index 0000000000..0963d7b4ba
--- /dev/null
+++ b/src/components/features/automations/detail/configuration-section.tsx
@@ -0,0 +1,85 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import type { Automation } from "#/types/automation";
+import CogIcon from "#/icons/cog.svg?react";
+import GitBranchIcon from "#/icons/git-branch.svg?react";
+import CheckCircleIcon from "#/icons/check-circle.svg?react";
+import CalendarIcon from "#/icons/calendar.svg?react";
+import SparkleIcon from "#/icons/sparkle.svg?react";
+import BellIcon from "#/icons/bell.svg?react";
+import { SectionCard } from "./section-card";
+import { ConfigField } from "./config-field";
+import { BranchBadge } from "./branch-badge";
+
+interface ConfigurationSectionProps {
+ automation: Automation;
+}
+
+export function ConfigurationSection({
+ automation,
+}: ConfigurationSectionProps) {
+ const { t } = useTranslation("openhands");
+
+ let scheduleDisplay = automation.trigger.schedule ?? "";
+ if (automation.trigger.schedule_human) {
+ scheduleDisplay = automation.timezone
+ ? `${automation.trigger.schedule_human} (${automation.timezone})`
+ : automation.trigger.schedule_human;
+ }
+
+ const triggerDisplay =
+ automation.trigger.type === "cron" ? "Schedule" : automation.trigger.type;
+
+ return (
+ }
+ title={t(I18nKey.AUTOMATIONS$DETAIL$CONFIGURATION)}
+ >
+
+ {automation.repository && (
+ }
+ label={t(I18nKey.AUTOMATIONS$DETAIL$REPOSITORIES)}
+ >
+
+ {automation.repository}
+ {automation.branch && }
+
+
+ )}
+
+ }
+ label={t(I18nKey.AUTOMATIONS$DETAIL$TRIGGER)}
+ >
+ {triggerDisplay}
+
+
+ }
+ label={t(I18nKey.AUTOMATIONS$DETAIL$SCHEDULE)}
+ >
+ {scheduleDisplay}
+
+
+ {automation.model && (
+ }
+ label={t(I18nKey.AUTOMATIONS$DETAIL$MODEL)}
+ >
+ {automation.model}
+
+ )}
+
+ {automation.notification && (
+ }
+ label={t(I18nKey.AUTOMATIONS$DETAIL$NOTIFICATION)}
+ >
+ {automation.notification}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/features/automations/detail/detail-header.tsx b/src/components/features/automations/detail/detail-header.tsx
new file mode 100644
index 0000000000..2865ceb734
--- /dev/null
+++ b/src/components/features/automations/detail/detail-header.tsx
@@ -0,0 +1,67 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import type { Automation } from "#/types/automation";
+import { ToggleSwitch } from "#/components/features/automations/toggle-switch";
+import { KebabMenu } from "#/components/features/automations/kebab-menu";
+import PowerIcon from "#/icons/power.svg?react";
+import TrashIcon from "#/icons/trash.svg?react";
+import { useHasPermission } from "#/hooks/use-has-permission";
+import { ActiveStatusBadge } from "./active-status-badge";
+
+interface DetailHeaderProps {
+ automation: Automation;
+ onToggle: () => void;
+ onDelete: () => void;
+}
+
+export function DetailHeader({
+ automation,
+ onToggle,
+ onDelete,
+}: DetailHeaderProps) {
+ const { t } = useTranslation("openhands");
+ const canManage = useHasPermission("manage_automations");
+
+ const kebabItems = [
+ {
+ label: automation.enabled
+ ? t(I18nKey.AUTOMATIONS$TURN_OFF)
+ : t(I18nKey.AUTOMATIONS$TURN_ON),
+ icon: ,
+ onClick: onToggle,
+ },
+ {
+ label: t(I18nKey.AUTOMATIONS$DELETE),
+ icon: ,
+ onClick: onDelete,
+ variant: "danger" as const,
+ },
+ ];
+
+ return (
+
+
+
+
+ {automation.name}
+
+
+
+
+ {canManage && (
+
+ )}
+ {canManage && }
+
+
+
+ );
+}
diff --git a/src/components/features/automations/detail/detail-skeleton.tsx b/src/components/features/automations/detail/detail-skeleton.tsx
new file mode 100644
index 0000000000..07d2fdd59c
--- /dev/null
+++ b/src/components/features/automations/detail/detail-skeleton.tsx
@@ -0,0 +1,28 @@
+function SkeletonBlock({ className }: { className: string }) {
+ return (
+
+ );
+}
+
+export function DetailSkeleton() {
+ return (
+
+ );
+}
diff --git a/src/components/features/automations/detail/not-found-state.tsx b/src/components/features/automations/detail/not-found-state.tsx
new file mode 100644
index 0000000000..785da91960
--- /dev/null
+++ b/src/components/features/automations/detail/not-found-state.tsx
@@ -0,0 +1,23 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import ExclamationCircleIcon from "#/icons/exclamation-circle.svg?react";
+import { BackLink } from "./back-link";
+
+export function NotFoundState() {
+ const { t } = useTranslation("openhands");
+
+ return (
+
+
+
+ {t(I18nKey.AUTOMATIONS$DETAIL$NOT_FOUND_TITLE)}
+
+
+ {t(I18nKey.AUTOMATIONS$DETAIL$NOT_FOUND_MESSAGE)}
+
+
+
+
+
+ );
+}
diff --git a/src/components/features/automations/detail/plugin-chip.tsx b/src/components/features/automations/detail/plugin-chip.tsx
new file mode 100644
index 0000000000..077981a7c0
--- /dev/null
+++ b/src/components/features/automations/detail/plugin-chip.tsx
@@ -0,0 +1,11 @@
+interface PluginChipProps {
+ name: string;
+}
+
+export function PluginChip({ name }: PluginChipProps) {
+ return (
+
+ {name}
+
+ );
+}
diff --git a/src/components/features/automations/detail/plugins-section.tsx b/src/components/features/automations/detail/plugins-section.tsx
new file mode 100644
index 0000000000..a43e22ce51
--- /dev/null
+++ b/src/components/features/automations/detail/plugins-section.tsx
@@ -0,0 +1,26 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import PuzzleIcon from "#/icons/puzzle.svg?react";
+import { SectionCard } from "./section-card";
+import { PluginChip } from "./plugin-chip";
+
+interface PluginsSectionProps {
+ plugins: string[];
+}
+
+export function PluginsSection({ plugins }: PluginsSectionProps) {
+ const { t } = useTranslation("openhands");
+
+ return (
+ }
+ title={t(I18nKey.AUTOMATIONS$DETAIL$PLUGINS)}
+ >
+
+ {plugins.map((plugin) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/features/automations/detail/prompt-section.tsx b/src/components/features/automations/detail/prompt-section.tsx
new file mode 100644
index 0000000000..188807958a
--- /dev/null
+++ b/src/components/features/automations/detail/prompt-section.tsx
@@ -0,0 +1,25 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import TerminalIcon from "#/icons/terminal.svg?react";
+import { SectionCard } from "./section-card";
+
+interface PromptSectionProps {
+ prompt: string;
+}
+
+export function PromptSection({ prompt }: PromptSectionProps) {
+ const { t } = useTranslation("openhands");
+
+ return (
+ }
+ title={t(I18nKey.AUTOMATIONS$DETAIL$PROMPT)}
+ >
+
+
+ );
+}
diff --git a/src/components/features/automations/detail/run-status-badge.tsx b/src/components/features/automations/detail/run-status-badge.tsx
new file mode 100644
index 0000000000..b053729c6f
--- /dev/null
+++ b/src/components/features/automations/detail/run-status-badge.tsx
@@ -0,0 +1,57 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import CheckCircleIcon from "#/icons/check-circle.svg?react";
+import XCircleIcon from "#/icons/x-circle.svg?react";
+import ClockIcon from "#/icons/clock.svg?react";
+import { AutomationRunStatus } from "#/types/automation";
+
+interface RunStatusBadgeProps {
+ status: AutomationRunStatus;
+}
+
+const statusConfig: Record<
+ AutomationRunStatus,
+ { label: I18nKey; style: string }
+> = {
+ [AutomationRunStatus.COMPLETED]: {
+ label: I18nKey.AUTOMATIONS$DETAIL$SUCCESSFUL,
+ style: "border-success/50 bg-success/10 text-success",
+ },
+ [AutomationRunStatus.FAILED]: {
+ label: I18nKey.AUTOMATIONS$DETAIL$FAILED,
+ style: "border-red-500/50 bg-red-500/10 text-red-400",
+ },
+ [AutomationRunStatus.PENDING]: {
+ label: I18nKey.AUTOMATIONS$DETAIL$PENDING,
+ style: "border-neutral-600 bg-neutral-700 text-neutral-400",
+ },
+ [AutomationRunStatus.RUNNING]: {
+ label: I18nKey.AUTOMATIONS$DETAIL$RUNNING,
+ style: "border-neutral-600 bg-neutral-700 text-neutral-400",
+ },
+};
+
+function StatusIcon({ status }: { status: AutomationRunStatus }) {
+ switch (status) {
+ case AutomationRunStatus.COMPLETED:
+ return ;
+ case AutomationRunStatus.FAILED:
+ return ;
+ default:
+ return ;
+ }
+}
+
+export function RunStatusBadge({ status }: RunStatusBadgeProps) {
+ const { t } = useTranslation("openhands");
+ const config = statusConfig[status];
+
+ return (
+
+
+ {t(config.label)}
+
+ );
+}
diff --git a/src/components/features/automations/detail/section-card.tsx b/src/components/features/automations/detail/section-card.tsx
new file mode 100644
index 0000000000..b50df654c2
--- /dev/null
+++ b/src/components/features/automations/detail/section-card.tsx
@@ -0,0 +1,17 @@
+interface SectionCardProps {
+ icon: React.ReactNode;
+ title: string;
+ children: React.ReactNode;
+}
+
+export function SectionCard({ icon, title, children }: SectionCardProps) {
+ return (
+
+
+ {icon}
+
{title}
+
+
{children}
+
+ );
+}
diff --git a/src/components/features/automations/empty-state.tsx b/src/components/features/automations/empty-state.tsx
new file mode 100644
index 0000000000..51f1bcbed6
--- /dev/null
+++ b/src/components/features/automations/empty-state.tsx
@@ -0,0 +1,22 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import DatabaseIcon from "#/icons/database.svg?react";
+import { CreateInstructions } from "./create-instructions";
+
+export function EmptyState() {
+ const { t } = useTranslation("openhands");
+
+ return (
+
+
+
+ {t(I18nKey.AUTOMATIONS$EMPTY)}
+
+
+ {/* How to create section */}
+
+
+
+
+ );
+}
diff --git a/src/components/features/automations/error-state.tsx b/src/components/features/automations/error-state.tsx
new file mode 100644
index 0000000000..d2c6b2fc8d
--- /dev/null
+++ b/src/components/features/automations/error-state.tsx
@@ -0,0 +1,27 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import ExclamationCircleIcon from "#/icons/exclamation-circle.svg?react";
+
+interface ErrorStateProps {
+ onRetry: () => void;
+}
+
+export function ErrorState({ onRetry }: ErrorStateProps) {
+ const { t } = useTranslation("openhands");
+
+ return (
+
+
+
+ {t(I18nKey.AUTOMATIONS$ERROR_TITLE)}
+
+
+
+ );
+}
diff --git a/src/components/features/automations/kebab-menu.tsx b/src/components/features/automations/kebab-menu.tsx
new file mode 100644
index 0000000000..bf656e7286
--- /dev/null
+++ b/src/components/features/automations/kebab-menu.tsx
@@ -0,0 +1,69 @@
+import { useState, useEffect, useRef } from "react";
+import KebabVerticalIcon from "#/icons/kebab-vertical.svg?react";
+
+export interface KebabMenuItem {
+ label: string;
+ icon: React.ReactNode;
+ onClick: () => void;
+ variant?: "default" | "danger";
+}
+
+interface KebabMenuProps {
+ items: KebabMenuItem[];
+}
+
+export function KebabMenu({ items }: KebabMenuProps) {
+ const [open, setOpen] = useState(false);
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return undefined;
+
+ const handleClickOutside = (e: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, [open]);
+
+ return (
+
+
+
+ {open && (
+
+ {items.map((item) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/features/automations/metadata-chip.tsx b/src/components/features/automations/metadata-chip.tsx
new file mode 100644
index 0000000000..c8871c3be2
--- /dev/null
+++ b/src/components/features/automations/metadata-chip.tsx
@@ -0,0 +1,13 @@
+interface MetadataChipProps {
+ icon: React.ReactNode;
+ label: string;
+}
+
+export function MetadataChip({ icon, label }: MetadataChipProps) {
+ return (
+
+ {icon}
+ {label}
+
+ );
+}
diff --git a/src/components/features/automations/search-input.tsx b/src/components/features/automations/search-input.tsx
new file mode 100644
index 0000000000..03b48f0d58
--- /dev/null
+++ b/src/components/features/automations/search-input.tsx
@@ -0,0 +1,25 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import SearchIcon from "#/icons/search.svg?react";
+
+interface SearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+}
+
+export function SearchInput({ value, onChange }: SearchInputProps) {
+ const { t } = useTranslation("openhands");
+
+ return (
+
+
+ onChange(e.target.value)}
+ placeholder={t(I18nKey.AUTOMATIONS$SEARCH_PLACEHOLDER)}
+ className="w-full max-w-sm rounded-lg border border-neutral-600 bg-neutral-800 py-2 pl-10 pr-3 text-sm text-white placeholder:text-neutral-400 focus:border-neutral-500 focus:outline-none"
+ />
+
+ );
+}
diff --git a/src/components/features/automations/status-badge.tsx b/src/components/features/automations/status-badge.tsx
new file mode 100644
index 0000000000..88af655078
--- /dev/null
+++ b/src/components/features/automations/status-badge.tsx
@@ -0,0 +1,11 @@
+interface StatusBadgeProps {
+ count: number;
+}
+
+export function StatusBadge({ count }: StatusBadgeProps) {
+ return (
+
+ {count}
+
+ );
+}
diff --git a/src/components/features/automations/toggle-switch.tsx b/src/components/features/automations/toggle-switch.tsx
new file mode 100644
index 0000000000..debb1cfd68
--- /dev/null
+++ b/src/components/features/automations/toggle-switch.tsx
@@ -0,0 +1,33 @@
+interface ToggleSwitchProps {
+ enabled: boolean;
+ label: string;
+ onToggle: () => void;
+}
+
+export function ToggleSwitch({ enabled, label, onToggle }: ToggleSwitchProps) {
+ return (
+
+ );
+}
diff --git a/src/hooks/query/use-automation-detail.ts b/src/hooks/query/use-automation-detail.ts
new file mode 100644
index 0000000000..85f7849348
--- /dev/null
+++ b/src/hooks/query/use-automation-detail.ts
@@ -0,0 +1,23 @@
+import { useQuery } from "@tanstack/react-query";
+import AutomationService from "#/api/automation-service/automation-service.api";
+
+export const AUTOMATION_DETAIL_QUERY_KEY = ["automation-detail"] as const;
+export const AUTOMATION_RUNS_QUERY_KEY = ["automation-runs"] as const;
+
+export function useAutomationDetail(id: string) {
+ return useQuery({
+ queryKey: [...AUTOMATION_DETAIL_QUERY_KEY, id],
+ queryFn: () => AutomationService.getAutomation(id),
+ staleTime: 5 * 60 * 1000,
+ enabled: !!id,
+ });
+}
+
+export function useAutomationRuns(id: string, limit = 20, offset = 0) {
+ return useQuery({
+ queryKey: [...AUTOMATION_RUNS_QUERY_KEY, id, { limit, offset }],
+ queryFn: () => AutomationService.getAutomationRuns(id, limit, offset),
+ staleTime: 60 * 1000,
+ enabled: !!id,
+ });
+}
diff --git a/src/hooks/query/use-automations.ts b/src/hooks/query/use-automations.ts
new file mode 100644
index 0000000000..40279bdcbe
--- /dev/null
+++ b/src/hooks/query/use-automations.ts
@@ -0,0 +1,35 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import AutomationService from "#/api/automation-service/automation-service.api";
+import { AUTOMATION_DETAIL_QUERY_KEY } from "./use-automation-detail";
+
+export const AUTOMATIONS_QUERY_KEY = ["automations"] as const;
+
+export function useAutomations(limit = 50, offset = 0) {
+ return useQuery({
+ queryKey: [...AUTOMATIONS_QUERY_KEY, { limit, offset }],
+ queryFn: () => AutomationService.getAutomations(limit, offset),
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useToggleAutomation() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
+ AutomationService.toggleAutomation(id, enabled),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: AUTOMATIONS_QUERY_KEY });
+ queryClient.invalidateQueries({ queryKey: AUTOMATION_DETAIL_QUERY_KEY });
+ },
+ });
+}
+
+export function useDeleteAutomation() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => AutomationService.deleteAutomation(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: AUTOMATIONS_QUERY_KEY });
+ },
+ });
+}
diff --git a/src/hooks/use-has-permission.ts b/src/hooks/use-has-permission.ts
new file mode 100644
index 0000000000..3d4d7258f1
--- /dev/null
+++ b/src/hooks/use-has-permission.ts
@@ -0,0 +1,6 @@
+// Simple permission hook for OSS agent-canvas
+// In the OSS context, all authenticated users have full access
+export function useHasPermission(_permission: string): boolean {
+ // In OSS mode, always return true - no permission restrictions
+ return true;
+}
diff --git a/src/i18n/translation.json b/src/i18n/translation.json
index d56b8ef35d..61eb8ac2b2 100644
--- a/src/i18n/translation.json
+++ b/src/i18n/translation.json
@@ -11123,6 +11123,218 @@
"uk": "Автоматизації",
"ca": "Automatitzacions"
},
+ "AUTOMATIONS$TITLE": {
+ "en": "Automations",
+ "ja": "オートメーション"
+ },
+ "AUTOMATIONS$SUBTITLE": {
+ "en": "View active and inactive automations, search by metadata, and inspect read-only details.",
+ "ja": "アクティブおよび非アクティブなオートメーションを表示し、メタデータで検索し、読み取り専用の詳細を確認します。"
+ },
+ "AUTOMATIONS$EMPTY": {
+ "en": "No automations configured",
+ "ja": "オートメーションが設定されていません"
+ },
+ "AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE": {
+ "en": "How to create an automation",
+ "ja": "オートメーションの作成方法"
+ },
+ "AUTOMATIONS$EMPTY_OPTION_PLUGIN_TITLE": {
+ "en": "Using Claude Code or Codex",
+ "ja": "Claude CodeまたはCodexを使用"
+ },
+ "AUTOMATIONS$EMPTY_OPTION_PLUGIN_DESC": {
+ "en": "and run the command:",
+ "ja": "以下のコマンドを実行:"
+ },
+ "AUTOMATIONS$EMPTY_INSTALL_PLUGIN": {
+ "en": "Install the OpenHands plugin",
+ "ja": "OpenHandsプラグインをインストール"
+ },
+ "AUTOMATIONS$EMPTY_OPTION_CONVERSATION_TITLE": {
+ "en": "Using OpenHands Cloud",
+ "ja": "OpenHands Cloudを使用"
+ },
+ "AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC": {
+ "en": "Start a new conversation and ask OpenHands to create an automation for you.",
+ "ja": "新しい会話を開始し、OpenHandsにオートメーションの作成を依頼してください。"
+ },
+ "AUTOMATIONS$EMPTY_START_CONVERSATION": {
+ "en": "Start a conversation",
+ "ja": "会話を開始"
+ },
+ "AUTOMATIONS$EMPTY_LEARN_MORE": {
+ "en": "Learn more in the documentation",
+ "ja": "ドキュメントで詳細を確認"
+ },
+ "AUTOMATIONS$SEARCH_PLACEHOLDER": {
+ "en": "Search automations...",
+ "ja": "オートメーションを検索..."
+ },
+ "AUTOMATIONS$ACTIVE": {
+ "en": "Active",
+ "ja": "アクティブ"
+ },
+ "AUTOMATIONS$INACTIVE": {
+ "en": "Inactive",
+ "ja": "非アクティブ"
+ },
+ "AUTOMATIONS$TURN_ON": {
+ "en": "Turn on",
+ "ja": "オンにする"
+ },
+ "AUTOMATIONS$TURN_OFF": {
+ "en": "Turn off",
+ "ja": "オフにする"
+ },
+ "AUTOMATIONS$DELETE": {
+ "en": "Delete",
+ "ja": "削除"
+ },
+ "AUTOMATIONS$DELETE_CONFIRM_TITLE": {
+ "en": "Delete automation",
+ "ja": "オートメーションを削除"
+ },
+ "AUTOMATIONS$DELETE_CONFIRM_MESSAGE": {
+ "en": "Delete \"{{name}}\"? This cannot be undone.",
+ "ja": "「{{name}}」を削除しますか?この操作は取り消せません。"
+ },
+ "AUTOMATIONS$CANCEL": {
+ "en": "Cancel",
+ "ja": "キャンセル"
+ },
+ "AUTOMATIONS$LOAD_MORE": {
+ "en": "Load more",
+ "ja": "さらに読み込む"
+ },
+ "AUTOMATIONS$ERROR_TITLE": {
+ "en": "Failed to load automations",
+ "ja": "オートメーションの読み込みに失敗しました"
+ },
+ "AUTOMATIONS$ERROR_RETRY": {
+ "en": "Retry",
+ "ja": "再試行"
+ },
+ "AUTOMATIONS$DETAIL$ACTIVE": {
+ "en": "Active",
+ "ja": "アクティブ"
+ },
+ "AUTOMATIONS$DETAIL$ACTIVITY": {
+ "en": "Activity",
+ "ja": "アクティビティ"
+ },
+ "AUTOMATIONS$DETAIL$ACTIVITY_LOG": {
+ "en": "Activity Log",
+ "ja": "アクティビティログ"
+ },
+ "AUTOMATIONS$DETAIL$BACK_TO_LIST": {
+ "en": "Back to Automations",
+ "ja": "オートメーション一覧に戻る"
+ },
+ "AUTOMATIONS$DETAIL$CONFIGURATION": {
+ "en": "Configuration",
+ "ja": "設定"
+ },
+ "AUTOMATIONS$DETAIL$CREATED": {
+ "en": "Created",
+ "ja": "作成日"
+ },
+ "AUTOMATIONS$DETAIL$FAILED": {
+ "en": "Failed",
+ "ja": "失敗"
+ },
+ "AUTOMATIONS$DETAIL$PENDING": {
+ "en": "Pending",
+ "ja": "保留中"
+ },
+ "AUTOMATIONS$DETAIL$RUNNING": {
+ "en": "Running",
+ "ja": "実行中"
+ },
+ "AUTOMATIONS$DETAIL$INACTIVE": {
+ "en": "Inactive",
+ "ja": "非アクティブ"
+ },
+ "AUTOMATIONS$DETAIL$LAST_RUN": {
+ "en": "Last run",
+ "ja": "最終実行"
+ },
+ "AUTOMATIONS$DETAIL$LOAD_MORE_RUNS": {
+ "en": "Load more",
+ "ja": "さらに読み込む"
+ },
+ "AUTOMATIONS$DETAIL$MODEL": {
+ "en": "Model",
+ "ja": "モデル"
+ },
+ "AUTOMATIONS$DETAIL$NO_RUNS": {
+ "en": "No activity yet",
+ "ja": "まだアクティビティはありません"
+ },
+ "AUTOMATIONS$DETAIL$NO_CONVERSATION": {
+ "en": "Conversation not created",
+ "ja": "会話が作成されていません"
+ },
+ "AUTOMATIONS$DETAIL$TIME_JUST_NOW": {
+ "en": "Just now",
+ "ja": "たった今"
+ },
+ "AUTOMATIONS$DETAIL$TIME_MINUTES_AGO": {
+ "en": "{{count}}m ago",
+ "ja": "{{count}}分前"
+ },
+ "AUTOMATIONS$DETAIL$TIME_HOURS_AGO": {
+ "en": "{{count}}h ago",
+ "ja": "{{count}}時間前"
+ },
+ "AUTOMATIONS$DETAIL$TIME_YESTERDAY": {
+ "en": "Yesterday",
+ "ja": "昨日"
+ },
+ "AUTOMATIONS$DETAIL$TIME_DAYS_AGO": {
+ "en": "{{count}}d ago",
+ "ja": "{{count}}日前"
+ },
+ "AUTOMATIONS$DETAIL$TIME_NEVER": {
+ "en": "Never",
+ "ja": "未実行"
+ },
+ "AUTOMATIONS$DETAIL$NOT_FOUND_MESSAGE": {
+ "en": "The automation you're looking for doesn't exist or has been deleted.",
+ "ja": "お探しのオートメーションは存在しないか、削除されています。"
+ },
+ "AUTOMATIONS$DETAIL$NOT_FOUND_TITLE": {
+ "en": "Automation not found",
+ "ja": "オートメーションが見つかりません"
+ },
+ "AUTOMATIONS$DETAIL$NOTIFICATION": {
+ "en": "Notification",
+ "ja": "通知"
+ },
+ "AUTOMATIONS$DETAIL$PLUGINS": {
+ "en": "Plugins",
+ "ja": "プラグイン"
+ },
+ "AUTOMATIONS$DETAIL$PROMPT": {
+ "en": "Prompt",
+ "ja": "プロンプト"
+ },
+ "AUTOMATIONS$DETAIL$REPOSITORIES": {
+ "en": "Repositories",
+ "ja": "リポジトリ"
+ },
+ "AUTOMATIONS$DETAIL$SCHEDULE": {
+ "en": "Schedule",
+ "ja": "スケジュール"
+ },
+ "AUTOMATIONS$DETAIL$SUCCESSFUL": {
+ "en": "Successful",
+ "ja": "成功"
+ },
+ "AUTOMATIONS$DETAIL$TRIGGER": {
+ "en": "Trigger",
+ "ja": "トリガー"
+ },
"SIDEBAR$CONVERSATIONS": {
"en": "Conversations",
"ja": "会話",
diff --git a/src/icons/activity.svg b/src/icons/activity.svg
new file mode 100644
index 0000000000..161729d381
--- /dev/null
+++ b/src/icons/activity.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/bell.svg b/src/icons/bell.svg
new file mode 100644
index 0000000000..b5d7f207d8
--- /dev/null
+++ b/src/icons/bell.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/calendar.svg b/src/icons/calendar.svg
new file mode 100644
index 0000000000..dd391d7531
--- /dev/null
+++ b/src/icons/calendar.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/check-circle.svg b/src/icons/check-circle.svg
new file mode 100644
index 0000000000..6eff56672c
--- /dev/null
+++ b/src/icons/check-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/chevron-down.svg b/src/icons/chevron-down.svg
new file mode 100644
index 0000000000..908fcc2d13
--- /dev/null
+++ b/src/icons/chevron-down.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/chevron-left.svg b/src/icons/chevron-left.svg
new file mode 100644
index 0000000000..b885c74ad3
--- /dev/null
+++ b/src/icons/chevron-left.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/clock.svg b/src/icons/clock.svg
new file mode 100644
index 0000000000..6db21960c0
--- /dev/null
+++ b/src/icons/clock.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/cog.svg b/src/icons/cog.svg
new file mode 100644
index 0000000000..0a424e353e
--- /dev/null
+++ b/src/icons/cog.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/icons/database.svg b/src/icons/database.svg
new file mode 100644
index 0000000000..92815ccce2
--- /dev/null
+++ b/src/icons/database.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/exclamation-circle.svg b/src/icons/exclamation-circle.svg
new file mode 100644
index 0000000000..b20b01a391
--- /dev/null
+++ b/src/icons/exclamation-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/git-branch.svg b/src/icons/git-branch.svg
new file mode 100644
index 0000000000..1f7fc2fc7e
--- /dev/null
+++ b/src/icons/git-branch.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/icons/kebab-vertical.svg b/src/icons/kebab-vertical.svg
new file mode 100644
index 0000000000..dc0958d997
--- /dev/null
+++ b/src/icons/kebab-vertical.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/power.svg b/src/icons/power.svg
new file mode 100644
index 0000000000..f8f8b8d561
--- /dev/null
+++ b/src/icons/power.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/puzzle.svg b/src/icons/puzzle.svg
new file mode 100644
index 0000000000..2e7b29504a
--- /dev/null
+++ b/src/icons/puzzle.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/search.svg b/src/icons/search.svg
new file mode 100644
index 0000000000..9a476e804b
--- /dev/null
+++ b/src/icons/search.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/sparkle.svg b/src/icons/sparkle.svg
new file mode 100644
index 0000000000..73e65f2bda
--- /dev/null
+++ b/src/icons/sparkle.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/target.svg b/src/icons/target.svg
new file mode 100644
index 0000000000..6eff56672c
--- /dev/null
+++ b/src/icons/target.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/trash.svg b/src/icons/trash.svg
new file mode 100644
index 0000000000..786b6e331a
--- /dev/null
+++ b/src/icons/trash.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/x-circle.svg b/src/icons/x-circle.svg
new file mode 100644
index 0000000000..6ddf2d1a6b
--- /dev/null
+++ b/src/icons/x-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/x-mark.svg b/src/icons/x-mark.svg
new file mode 100644
index 0000000000..bec5620217
--- /dev/null
+++ b/src/icons/x-mark.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/mocks/automation-handlers.ts b/src/mocks/automation-handlers.ts
new file mode 100644
index 0000000000..8c87c6f305
--- /dev/null
+++ b/src/mocks/automation-handlers.ts
@@ -0,0 +1,123 @@
+import { http, HttpResponse, delay } from "msw";
+import type {
+ Automation,
+ AutomationsResponse,
+ AutomationRunsResponse,
+} from "#/types/automation";
+import { MOCK_AUTOMATIONS_RESPONSE } from "./automations.mock";
+import { MOCK_AUTOMATION_RUNS } from "./automation-runs.mock";
+
+// Mutable copy for CRUD operations within the mock session
+const automations = new Map(
+ MOCK_AUTOMATIONS_RESPONSE.automations.map((a) => [a.id, { ...a }]),
+);
+
+export const resetAutomationMockData = () => {
+ automations.clear();
+ MOCK_AUTOMATIONS_RESPONSE.automations.forEach((a) => {
+ automations.set(a.id, { ...a });
+ });
+};
+
+export const AUTOMATION_HANDLERS = [
+ // GET /api/automation/v1 — List automations
+ http.get("/api/automation/v1", async ({ request }) => {
+ await delay(300);
+
+ const url = new URL(request.url);
+ const limit = Number(url.searchParams.get("limit") ?? "50");
+ const offset = Number(url.searchParams.get("offset") ?? "0");
+
+ const all = Array.from(automations.values());
+ const page = all.slice(offset, offset + limit);
+
+ const response: AutomationsResponse = {
+ automations: page,
+ total: all.length,
+ };
+
+ return HttpResponse.json(response);
+ }),
+
+ // GET /api/automation/v1/:id/runs — List automation runs
+ http.get("/api/automation/v1/:id/runs", async ({ params, request }) => {
+ await delay(200);
+
+ const id = params.id as string;
+ if (!automations.has(id)) {
+ return HttpResponse.json(
+ { detail: "Automation not found" },
+ { status: 404 },
+ );
+ }
+
+ const url = new URL(request.url);
+ const limit = Number(url.searchParams.get("limit") ?? "50");
+ const offset = Number(url.searchParams.get("offset") ?? "0");
+
+ const allRuns = MOCK_AUTOMATION_RUNS[id] ?? [];
+ const page = allRuns.slice(offset, offset + limit);
+
+ const response: AutomationRunsResponse = {
+ runs: page,
+ total: allRuns.length,
+ };
+
+ return HttpResponse.json(response);
+ }),
+
+ // GET /api/automation/v1/:id — Get automation detail
+ http.get("/api/automation/v1/:id", async ({ params }) => {
+ await delay(200);
+
+ const automation = automations.get(params.id as string);
+ if (!automation) {
+ return HttpResponse.json(
+ { detail: "Automation not found" },
+ { status: 404 },
+ );
+ }
+
+ return HttpResponse.json(automation);
+ }),
+
+ // PATCH /api/automation/v1/:id — Update automation (toggle enabled)
+ http.patch("/api/automation/v1/:id", async ({ params, request }) => {
+ await delay(200);
+
+ const id = params.id as string;
+ const automation = automations.get(id);
+ if (!automation) {
+ return HttpResponse.json(
+ { detail: "Automation not found" },
+ { status: 404 },
+ );
+ }
+
+ const body = (await request.json()) as Partial;
+ const updated: Automation = {
+ ...automation,
+ ...body,
+ updated_at: new Date().toISOString(),
+ };
+ automations.set(id, updated);
+
+ return HttpResponse.json(updated);
+ }),
+
+ // DELETE /api/automation/v1/:id — Delete automation
+ http.delete("/api/automation/v1/:id", async ({ params }) => {
+ await delay(200);
+
+ const id = params.id as string;
+ if (!automations.has(id)) {
+ return HttpResponse.json(
+ { detail: "Automation not found" },
+ { status: 404 },
+ );
+ }
+
+ automations.delete(id);
+ return new HttpResponse(null, { status: 204 });
+ }),
+];
diff --git a/src/mocks/automation-runs.mock.ts b/src/mocks/automation-runs.mock.ts
new file mode 100644
index 0000000000..b4b1b5325b
--- /dev/null
+++ b/src/mocks/automation-runs.mock.ts
@@ -0,0 +1,61 @@
+import { AutomationRunStatus } from "#/types/automation";
+import type { AutomationRun } from "#/types/automation";
+
+const daysAgo = (days: number, hour = 9) => {
+ const d = new Date(Date.now() - days * 86_400_000);
+ d.setHours(hour, 0, 0, 0);
+ return d.toISOString();
+};
+
+function makeRun(
+ id: string,
+ status: AutomationRunStatus,
+ startedDaysAgo: number,
+ hour = 9,
+ hasConversation = true,
+): AutomationRun {
+ const started = daysAgo(startedDaysAgo, hour);
+ return {
+ id,
+ status,
+ conversation_id: hasConversation ? `conv-${id}` : null,
+ error_detail:
+ status === AutomationRunStatus.FAILED
+ ? "Process exited with code 1"
+ : null,
+ started_at: started,
+ completed_at: new Date(new Date(started).getTime() + 120_000).toISOString(),
+ };
+}
+
+export const MOCK_AUTOMATION_RUNS: Record = {
+ "a1000000-0000-0000-0000-000000000001": [
+ makeRun("r1-01", AutomationRunStatus.COMPLETED, 0),
+ makeRun("r1-02", AutomationRunStatus.COMPLETED, 1),
+ makeRun("r1-03", AutomationRunStatus.FAILED, 2),
+ makeRun("r1-04", AutomationRunStatus.COMPLETED, 3),
+ makeRun("r1-05", AutomationRunStatus.COMPLETED, 4),
+ makeRun("r1-06", AutomationRunStatus.COMPLETED, 7),
+ makeRun("r1-07", AutomationRunStatus.FAILED, 8),
+ makeRun("r1-08", AutomationRunStatus.COMPLETED, 9),
+ makeRun("r1-09", AutomationRunStatus.COMPLETED, 10),
+ makeRun("r1-10", AutomationRunStatus.COMPLETED, 11),
+ ],
+ "a1000000-0000-0000-0000-000000000002": [
+ makeRun("r2-01", AutomationRunStatus.COMPLETED, 0, 1),
+ makeRun("r2-02", AutomationRunStatus.COMPLETED, 1, 1),
+ makeRun("r2-03", AutomationRunStatus.COMPLETED, 2, 1),
+ makeRun("r2-04", AutomationRunStatus.FAILED, 3, 1),
+ makeRun("r2-05", AutomationRunStatus.COMPLETED, 4, 1),
+ ],
+ "a1000000-0000-0000-0000-000000000003": [
+ makeRun("r3-01", AutomationRunStatus.COMPLETED, 1),
+ makeRun("r3-02", AutomationRunStatus.COMPLETED, 2),
+ makeRun("r3-03", AutomationRunStatus.COMPLETED, 3),
+ ],
+ "a1000000-0000-0000-0000-000000000004": [
+ makeRun("r4-01", AutomationRunStatus.FAILED, 14, 11, false), // Failed before sandbox creation
+ makeRun("r4-02", AutomationRunStatus.COMPLETED, 21, 11),
+ ],
+ "a1000000-0000-0000-0000-000000000005": [],
+};
diff --git a/src/mocks/automations.mock.ts b/src/mocks/automations.mock.ts
new file mode 100644
index 0000000000..b89f1995fa
--- /dev/null
+++ b/src/mocks/automations.mock.ts
@@ -0,0 +1,127 @@
+import type { AutomationsResponse } from "#/types/automation";
+
+/**
+ * Mock automations data matching the AutomationResponse schema from the backend.
+ *
+ * Backend schema fields: id, user_id, org_id, name, trigger (JSONB),
+ * tarball_path, setup_script_path, entrypoint, timeout, enabled,
+ * last_triggered_at, created_at, updated_at.
+ *
+ * The frontend Automation type only uses a subset of these fields.
+ * Additional backend fields are included here for API fidelity.
+ */
+
+const now = new Date().toISOString();
+const daysAgo = (days: number) =>
+ new Date(Date.now() - days * 86_400_000).toISOString();
+
+export const MOCK_AUTOMATIONS_RESPONSE: AutomationsResponse = {
+ automations: [
+ {
+ id: "a1000000-0000-0000-0000-000000000001",
+ name: "PR Triage Digest",
+ trigger: {
+ type: "cron",
+ schedule: "0 9 * * 1-5",
+ schedule_human: "Weekdays at 09:00",
+ },
+ enabled: true,
+ repository: "acme/frontend-app",
+ model: "Claude Opus",
+ created_at: daysAgo(90),
+ updated_at: now,
+ prompt:
+ "Review newly opened pull requests in acme/frontend-app, identify risky changes, summarize likely impact, and prepare a concise digest with priority ordering for the engineering review channel.",
+ branch: "main",
+ plugins: ["GitHub", "Slack", "Linear"],
+ notification: "Slack digest to #eng-reviews",
+ timezone: "America/Los_Angeles",
+ last_triggered_at: daysAgo(0),
+ },
+ {
+ id: "a1000000-0000-0000-0000-000000000002",
+ name: "Nightly Security Pass",
+ trigger: {
+ type: "cron",
+ schedule: "30 1 * * *",
+ schedule_human: "Daily at 01:30",
+ },
+ enabled: true,
+ repository: "acme/backend-api",
+ model: "GPT-5",
+ created_at: daysAgo(60),
+ updated_at: now,
+ prompt:
+ "Scan the acme/backend-api repository for known security vulnerabilities, outdated dependencies, and insecure code patterns. Produce a prioritized remediation summary.",
+ branch: "main",
+ plugins: ["GitHub"],
+ notification: "Email to security-team@acme.com",
+ timezone: "UTC",
+ last_triggered_at: daysAgo(0),
+ },
+ {
+ id: "a1000000-0000-0000-0000-000000000003",
+ name: "Docs Sync on Push",
+ trigger: {
+ type: "cron",
+ schedule: "*/5 * * * *",
+ schedule_human: "Runs on every push",
+ },
+ enabled: true,
+ repository: "acme/docs",
+ model: "GPT-4o",
+ created_at: daysAgo(45),
+ updated_at: now,
+ prompt:
+ "Monitor acme/docs for new pushes. For each push, generate a changelog-ready summary of what changed and why.",
+ branch: "main",
+ plugins: ["GitHub", "Slack"],
+ notification: "Slack to #docs-updates",
+ timezone: "America/New_York",
+ last_triggered_at: daysAgo(1),
+ },
+ {
+ id: "a1000000-0000-0000-0000-000000000004",
+ name: "Release Readiness Review",
+ trigger: {
+ type: "cron",
+ schedule: "0 11 * * 5",
+ schedule_human: "Fridays at 11:00",
+ },
+ enabled: false,
+ repository: "acme/realtime-service",
+ model: "Gemini 2.5 Pro",
+ created_at: daysAgo(80),
+ updated_at: now,
+ prompt:
+ "Compile a release readiness report: list open blockers, active incidents, and pending approvals for acme/realtime-service.",
+ branch: "release",
+ plugins: ["GitHub", "Linear"],
+ notification: "Slack to #releases",
+ timezone: "America/Chicago",
+ last_triggered_at: daysAgo(14),
+ },
+ {
+ id: "a1000000-0000-0000-0000-000000000005",
+ name: "Incident Webhook Summary",
+ trigger: {
+ type: "cron",
+ schedule: "0 */2 * * *",
+ schedule_human: "On incident webhook",
+ },
+ enabled: false,
+ repository: "acme/incident-service",
+ model: "Claude Sonnet",
+ created_at: daysAgo(30),
+ updated_at: now,
+ prompt:
+ "Summarize incoming incident webhooks, categorize by severity, and post a digest to the on-call Slack channel.",
+ branch: "main",
+ plugins: ["Slack"],
+ notification: "Slack to #oncall",
+ timezone: "UTC",
+ last_triggered_at: null,
+ },
+ ],
+ total: 5,
+};
diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts
index 300deed564..f5f9c5f7c6 100644
--- a/src/mocks/handlers.ts
+++ b/src/mocks/handlers.ts
@@ -12,6 +12,10 @@ import { CONVERSATION_HANDLERS } from "./conversation-handlers";
import { AUTH_HANDLERS } from "./auth-handlers";
import { FEEDBACK_HANDLERS } from "./feedback-handlers";
import { ANALYTICS_HANDLERS } from "./analytics-handlers";
+import {
+ AUTOMATION_HANDLERS,
+ resetAutomationMockData,
+} from "./automation-handlers";
export const handlers = [
...API_KEYS_HANDLERS,
@@ -24,6 +28,11 @@ export const handlers = [
...AUTH_HANDLERS,
...FEEDBACK_HANDLERS,
...ANALYTICS_HANDLERS,
+ ...AUTOMATION_HANDLERS,
];
-export { MOCK_DEFAULT_USER_SETTINGS, resetTestHandlersMockSettings };
+export {
+ MOCK_DEFAULT_USER_SETTINGS,
+ resetTestHandlersMockSettings,
+ resetAutomationMockData,
+};
diff --git a/src/routes.ts b/src/routes.ts
index 903369d82b..0eab31ff98 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -22,6 +22,8 @@ export default [
]),
route("conversations/:conversationId", "routes/conversation.tsx"),
route("oauth/device/verify", "routes/device-verify.tsx"),
+ route("automations", "routes/automations-list.tsx"),
+ route("automations/:automationId", "routes/automation-detail.tsx"),
]),
route(
"shared/conversations/:conversationId",
diff --git a/src/routes/automation-detail.tsx b/src/routes/automation-detail.tsx
new file mode 100644
index 0000000000..a81dea44a0
--- /dev/null
+++ b/src/routes/automation-detail.tsx
@@ -0,0 +1,108 @@
+import { useState } from "react";
+import { useParams } from "react-router";
+import { isAxiosError } from "axios";
+import { useAutomationDetail } from "#/hooks/query/use-automation-detail";
+import {
+ useToggleAutomation,
+ useDeleteAutomation,
+} from "#/hooks/query/use-automations";
+import { useNavigation } from "#/context/navigation-context";
+import { BackLink } from "#/components/features/automations/detail/back-link";
+import { DetailHeader } from "#/components/features/automations/detail/detail-header";
+import { PromptSection } from "#/components/features/automations/detail/prompt-section";
+import { ConfigurationSection } from "#/components/features/automations/detail/configuration-section";
+import { PluginsSection } from "#/components/features/automations/detail/plugins-section";
+import { ActivitySection } from "#/components/features/automations/detail/activity-section";
+import { ActivityLogSection } from "#/components/features/automations/detail/activity-log-section";
+import { DetailSkeleton } from "#/components/features/automations/detail/detail-skeleton";
+import { NotFoundState } from "#/components/features/automations/detail/not-found-state";
+import { ErrorState } from "#/components/features/automations/error-state";
+import { DeleteConfirmationModal } from "#/components/features/automations/delete-confirmation-modal";
+
+export default function AutomationDetail() {
+ const { automationId } = useParams();
+ const { navigate } = useNavigation();
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+
+ const {
+ data: automation,
+ isLoading,
+ isError,
+ error,
+ refetch,
+ } = useAutomationDetail(automationId ?? "");
+
+ const toggleMutation = useToggleAutomation();
+ const deleteMutation = useDeleteAutomation();
+
+ const is404 =
+ isError && isAxiosError(error) && error.response?.status === 404;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (is404) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError || !automation) {
+ return (
+
+ refetch()} />
+
+ );
+ }
+
+ const handleToggle = () => {
+ toggleMutation.mutate({
+ id: automation.id,
+ enabled: !automation.enabled,
+ });
+ };
+
+ const handleDelete = () => {
+ deleteMutation.mutate(automation.id, {
+ onSuccess: () => {
+ navigate?.("/automations");
+ },
+ });
+ };
+
+ return (
+
+
+
+
setShowDeleteModal(true)}
+ />
+ {automation.prompt && }
+
+ {automation.plugins && automation.plugins.length > 0 && (
+
+ )}
+
+
+ setShowDeleteModal(false)}
+ />
+
+
+ );
+}
diff --git a/src/routes/automations-list.tsx b/src/routes/automations-list.tsx
new file mode 100644
index 0000000000..879ef6807c
--- /dev/null
+++ b/src/routes/automations-list.tsx
@@ -0,0 +1,144 @@
+import { useState, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import {
+ useAutomations,
+ useToggleAutomation,
+ useDeleteAutomation,
+} from "#/hooks/query/use-automations";
+import { SearchInput } from "#/components/features/automations/search-input";
+import { AutomationGroup } from "#/components/features/automations/automation-group";
+import { AutomationCardSkeleton } from "#/components/features/automations/automation-card-skeleton";
+import { EmptyState } from "#/components/features/automations/empty-state";
+import { ErrorState } from "#/components/features/automations/error-state";
+import { DeleteConfirmationModal } from "#/components/features/automations/delete-confirmation-modal";
+import { CreateInstructions } from "#/components/features/automations/create-instructions";
+
+const PAGE_SIZE = 50;
+
+export default function AutomationsList() {
+ const { t } = useTranslation("openhands");
+ const [searchQuery, setSearchQuery] = useState("");
+ const [limit, setLimit] = useState(PAGE_SIZE);
+ const [deleteTarget, setDeleteTarget] = useState<{
+ id: string;
+ name: string;
+ } | null>(null);
+
+ const { data, isLoading, isError, refetch } = useAutomations(limit, 0);
+ const toggleMutation = useToggleAutomation();
+ const deleteMutation = useDeleteAutomation();
+
+ const filtered = useMemo(() => {
+ if (!data?.automations) return [];
+ const q = searchQuery.toLowerCase();
+ if (!q) return data.automations;
+ return data.automations.filter(
+ (a) =>
+ a.name.toLowerCase().includes(q) ||
+ (a.prompt ?? "").toLowerCase().includes(q) ||
+ a.repository?.toLowerCase().includes(q) ||
+ a.model?.toLowerCase().includes(q),
+ );
+ }, [data?.automations, searchQuery]);
+
+ const active = useMemo(() => filtered.filter((a) => a.enabled), [filtered]);
+ const inactive = useMemo(
+ () => filtered.filter((a) => !a.enabled),
+ [filtered],
+ );
+
+ const handleToggle = (id: string, currentEnabled: boolean) => {
+ toggleMutation.mutate({ id, enabled: !currentEnabled });
+ };
+
+ const handleDeleteRequest = (id: string) => {
+ const automation = data?.automations.find((a) => a.id === id);
+ if (automation) {
+ setDeleteTarget({ id, name: automation.name });
+ }
+ };
+
+ const handleDeleteConfirm = () => {
+ if (deleteTarget) {
+ deleteMutation.mutate(deleteTarget.id);
+ setDeleteTarget(null);
+ }
+ };
+
+ const hasMore = data ? data.total > data.automations.length : false;
+
+ return (
+
+ {/* Header */}
+
+ {t(I18nKey.AUTOMATIONS$TITLE)}
+
+
+ {t(I18nKey.AUTOMATIONS$SUBTITLE)}
+
+
+ {/* Search */}
+
+
+
+
+ {/* Content */}
+
+ {isLoading && (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {isError && !isLoading &&
}
+
+ {!isLoading && !isError && data && data.automations.length === 0 && (
+
+ )}
+
+ {!isLoading && !isError && data && data.automations.length > 0 && (
+ <>
+ {/* Collapsible creation instructions at the top */}
+
+
+
+
+
+ {hasMore && (
+
+ )}
+ >
+ )}
+
+
+ {/* Delete confirmation modal */}
+
setDeleteTarget(null)}
+ />
+
+ );
+}
diff --git a/src/types/automation.ts b/src/types/automation.ts
new file mode 100644
index 0000000000..2b08d7c215
--- /dev/null
+++ b/src/types/automation.ts
@@ -0,0 +1,48 @@
+export interface AutomationTrigger {
+ type: string;
+ schedule?: string;
+ schedule_human?: string;
+}
+
+export interface Automation {
+ id: string;
+ name: string;
+ trigger: AutomationTrigger;
+ enabled: boolean;
+ repository?: string;
+ model?: string;
+ created_at: string;
+ updated_at: string;
+ prompt: string | null;
+ branch?: string;
+ plugins?: string[];
+ notification?: string;
+ timezone?: string;
+ last_triggered_at?: string | null;
+}
+
+export interface AutomationsResponse {
+ automations: Automation[];
+ total: number;
+}
+
+export enum AutomationRunStatus {
+ PENDING = "PENDING",
+ RUNNING = "RUNNING",
+ COMPLETED = "COMPLETED",
+ FAILED = "FAILED",
+}
+
+export interface AutomationRun {
+ id: string;
+ status: AutomationRunStatus;
+ conversation_id: string | null;
+ error_detail: string | null;
+ started_at: string;
+ completed_at: string | null;
+}
+
+export interface AutomationRunsResponse {
+ runs: AutomationRun[];
+ total: number;
+}
diff --git a/test-utils.tsx b/test-utils.tsx
index 5bf5c784e9..f729392963 100644
--- a/test-utils.tsx
+++ b/test-utils.tsx
@@ -34,20 +34,24 @@ vi.mock("react-router", async () => {
};
});
-// Initialize i18n for tests
+// Initialize i18n for tests - return key when translation is missing
i18n.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
- ns: ["translation"],
+ ns: ["translation", "openhands"],
defaultNS: "translation",
resources: {
en: {
translation: {},
+ openhands: {},
},
},
interpolation: {
escapeValue: false,
},
+ returnEmptyString: false,
+ missingKeyHandler: false,
+ saveMissing: false,
});
const createNavigationValue = (