feat: add automations frontend on /automations subpath (#141)

* feat: add automations frontend on /automations subpath

Port automations frontend from automation-repo to agent-canvas.

## Changes

### New Routes
- /automations - List view of all automations
- /automations/:automationId - Automation detail view

### New Components
- Automation list components: card, card-skeleton, group, empty-state, error-state
- Automation detail components: header, sections (config, prompt, plugins, activity)
- Shared UI components: toggle-switch, metadata-chip, status-badge, kebab-menu, search-input

### API Integration
- automation-service.api.ts - API client for automation CRUD operations
- Uses existing openHands axios client (shared base URL with agent server)

### MSW Mock Server Handlers
- automation-handlers.ts - Mock handlers for testing
- automations.mock.ts - Sample automation data
- automation-runs.mock.ts - Sample automation run data

### Tests
- API tests: automation-service.test.ts, automation-handlers.test.ts
- Component tests: toggle-switch, metadata-chip, search-input, error-state
- Detail component tests: section-card, run-status-badge, not-found-state

### Hooks
- use-automations.ts - React Query hook for fetching automations list
- use-automation-detail.ts - React Query hook for fetching single automation
- use-has-permission.ts - Permission checking utility hook

### Types
- automation.ts - TypeScript types for automation entities

### Icons
- Added SVG icons: activity, bell, calendar, check-circle, chevron-down,
  chevron-left, clock, cog, database, exclamation-circle, git-branch,
  kebab-vertical, power, puzzle, search, sparkle, target, trash, x-circle, x-mark

Co-authored-by: openhands <openhands@all-hands.dev>

* feat: add local API key auth for automation backend

- Use VITE_AUTOMATION_API_KEY env var for frontend to authenticate
- Pass AUTOMATION_LOCAL_API_KEY to automation backend in dev mode
- Use dedicated axios instance with Bearer auth interceptor
- Add --refresh to uvx to ensure latest git commits are fetched
- URL-encode automation IDs in API paths

The default local API key is 'openhands-local-api-key' which matches
between the frontend and backend for local development.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: automation service tests and ingress port conflicts

- Fix automation-service.test.ts to mock axios instance correctly
  (was mocking openHands but service uses automationAxios)
- Use vi.hoisted() for mock functions available during vi.mock hoisting
- Change ingress test ports from 19000-19003 to 29000-29003 to avoid
  conflict with VS Code server on port 19000

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: add CORS origins for automation backend in dev mode

The automation backend defaults CORS origins to app.all-hands.dev,
which blocks localhost requests. Add localhost origins for the
ingress port and Vite dev server port.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: update create-instructions styling and add missing i18n keys

- Use semantic color tokens (text-content, text-basic, bg-base-secondary,
  bg-base, border-default) instead of hardcoded neutral-* colors
- Add all AUTOMATIONS$ i18n keys for the automations frontend

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
Rohit Malhotra
2026-05-08 01:49:13 +07:00
committed by GitHub
co-authored by openhands hieptl
parent 48b275a94f
commit 6ebe7b4e7e
73 changed files with 2777 additions and 13 deletions
+123
View File
@@ -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);
});
});
});
+203
View File
@@ -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);
});
});
});
@@ -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(<NotFoundState />);
// 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");
});
});
@@ -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(
<RunStatusBadge status={AutomationRunStatus.COMPLETED} />,
);
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(
<RunStatusBadge status={AutomationRunStatus.FAILED} />,
);
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(
<RunStatusBadge status={AutomationRunStatus.PENDING} />,
);
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(
<RunStatusBadge status={AutomationRunStatus.RUNNING} />,
);
const badge = container.querySelector("span");
expect(badge).toBeInTheDocument();
expect(badge?.className).toContain("text-neutral-400");
});
});
@@ -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(
<SectionCard icon={<span data-testid="icon" />} title="Test Section">
<p>{String("Section content")}</p>
</SectionCard>,
);
expect(screen.getByText("Test Section")).toBeInTheDocument();
expect(screen.getByText("Section content")).toBeInTheDocument();
expect(screen.getByTestId("icon")).toBeInTheDocument();
});
});
@@ -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(<ErrorState onRetry={vi.fn()} />);
// 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(<ErrorState onRetry={onRetry} />);
await user.click(screen.getByRole("button"));
expect(onRetry).toHaveBeenCalledOnce();
});
});
@@ -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(
<MetadataChip
icon={<span data-testid="chip-icon" />}
label="acme/frontend-app"
/>,
);
expect(screen.getByText("acme/frontend-app")).toBeInTheDocument();
expect(screen.getByTestId("chip-icon")).toBeInTheDocument();
});
});
@@ -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(<SearchInput value="" onChange={onChange} />);
const input = screen.getByRole("textbox");
await user.type(input, "test");
expect(onChange).toHaveBeenCalled();
expect(onChange).toHaveBeenLastCalledWith("t");
});
it("displays the current value", () => {
render(<SearchInput value="security" onChange={vi.fn()} />);
expect(screen.getByRole("textbox")).toHaveValue("security");
});
});
@@ -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(
<ToggleSwitch enabled={false} label="Toggle test" onToggle={onToggle} />,
);
await user.click(screen.getByRole("switch"));
expect(onToggle).toHaveBeenCalledOnce();
});
it("reflects enabled state via aria-checked", () => {
render(<ToggleSwitch enabled label="Toggle test" onToggle={vi.fn()} />);
expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true");
});
it("stops event propagation on click", async () => {
const parentClick = vi.fn();
const user = userEvent.setup();
render(
<div onClick={parentClick} onKeyDown={parentClick} role="presentation">
<ToggleSwitch enabled={false} label="Toggle test" onToggle={vi.fn()} />
</div>,
);
await user.click(screen.getByRole("switch"));
expect(parentClick).not.toHaveBeenCalled();
});
});
+5 -4
View File
@@ -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,
[
+19 -6
View File
@@ -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,
});
@@ -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<AutomationsResponse> {
const { limit = 50, offset = 0 } = params;
const { data } = await automationAxios.get<AutomationsResponse>(
`${AUTOMATION_BASE_PATH}/v1`,
{
params: { limit, offset },
},
);
return data;
}
static async getAutomations(
limit = 50,
offset = 0,
): Promise<AutomationsResponse> {
return AutomationService.listAutomations({ limit, offset });
}
static async getAutomation(id: string): Promise<Automation> {
const { data } = await automationAxios.get<Automation>(
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
);
return data;
}
static async updateAutomation(
id: string,
body: Partial<Automation>,
): Promise<Automation> {
const { data } = await automationAxios.patch<Automation>(
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
body,
);
return data;
}
static async deleteAutomation(id: string): Promise<void> {
await automationAxios.delete(
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
);
}
static async listAutomationRuns(
id: string,
params: { limit?: number; offset?: number } = {},
): Promise<AutomationRunsResponse> {
const { limit = 50, offset = 0 } = params;
const { data } = await automationAxios.get<AutomationRunsResponse>(
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}/runs`,
{ params: { limit, offset } },
);
return data;
}
static async getAutomationRuns(
id: string,
limit = 50,
offset = 0,
): Promise<AutomationRunsResponse> {
return AutomationService.listAutomationRuns(id, { limit, offset });
}
static async toggleAutomation(
id: string,
enabled: boolean,
): Promise<Automation> {
return AutomationService.updateAutomation(id, { enabled });
}
}
export default AutomationService;
@@ -0,0 +1,19 @@
export function AutomationCardSkeleton() {
return (
<div
data-testid="automation-card-skeleton"
className="rounded-2xl border border-neutral-700 bg-neutral-800 p-5"
>
<div className="flex items-start justify-between">
<div className="h-5 w-40 animate-pulse rounded bg-neutral-700" />
<div className="h-5 w-10 animate-pulse rounded-full bg-neutral-700" />
</div>
<div className="mt-2 h-4 w-72 animate-pulse rounded bg-neutral-700" />
<div className="mt-4 flex gap-2">
<div className="h-7 w-32 animate-pulse rounded-full bg-neutral-700" />
<div className="h-7 w-28 animate-pulse rounded-full bg-neutral-700" />
<div className="h-7 w-24 animate-pulse rounded-full bg-neutral-700" />
</div>
</div>
);
}
@@ -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: <PowerIcon className="size-4" />,
onClick: () => onToggle(automation.id, automation.enabled),
},
{
label: t(I18nKey.AUTOMATIONS$DELETE),
icon: <TrashIcon className="size-4" />,
onClick: () => onDelete(automation.id),
variant: "danger",
},
];
const handleCardClick = () => {
navigate?.(`/automations/${automation.id}`);
};
return (
<div
role="link"
tabIndex={0}
onClick={handleCardClick}
onKeyDown={(e) => {
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"
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<h3 className="truncate text-base font-semibold text-white">
{automation.name}
</h3>
<p className="mt-1 line-clamp-2 text-sm text-neutral-400">
{automation.prompt}
</p>
</div>
<div className="ml-4 flex shrink-0 items-center gap-2">
{canManage && (
<ToggleSwitch
enabled={automation.enabled}
label={`Toggle ${automation.name}`}
onToggle={() => onToggle(automation.id, automation.enabled)}
/>
)}
{canManage && <KebabMenu items={menuItems} />}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{automation.repository && (
<MetadataChip
icon={<FolderIcon className="size-3.5" />}
label={automation.repository}
/>
)}
<MetadataChip
icon={<ClockIcon className="size-3.5" />}
label={scheduleLabel}
/>
{automation.model && (
<MetadataChip
icon={<SparkleIcon className="size-3.5" />}
label={automation.model}
/>
)}
</div>
</div>
);
}
@@ -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 (
<section>
<div className="flex items-center">
<h2 className="text-sm font-semibold text-white">{title}</h2>
<StatusBadge count={count} />
</div>
<div className="mt-3 flex flex-col gap-3">
{automations.map((automation) => (
<AutomationCard
key={automation.id}
automation={automation}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</div>
</section>
);
}
@@ -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 = (
<>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{/* Option 1: Claude Code / Codex */}
<div className="rounded-lg border border-default bg-base-secondary p-4">
<div className="flex items-center gap-2">
<TerminalIcon className="size-5 text-basic" />
<span className="text-sm font-medium text-content">
{t(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_TITLE)}
</span>
</div>
<p className="mt-2 text-sm text-basic">
<a
href={PLUGIN_INSTALL_URL}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-content transition-colors"
>
{t(I18nKey.AUTOMATIONS$EMPTY_INSTALL_PLUGIN)}
</a>{" "}
{t(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_DESC)}
</p>
<code className="mt-2 block rounded bg-base px-3 py-2 font-mono text-xs text-content">
{PLUGIN_COMMAND}
</code>
</div>
{/* Option 2: OpenHands Cloud conversation */}
<div className="rounded-lg border border-default bg-base-secondary p-4">
<div className="flex items-center gap-2">
<SparkleIcon className="size-5 text-basic" />
<span className="text-sm font-medium text-content">
{t(I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_TITLE)}
</span>
</div>
<p className="mt-2 text-sm text-basic">
{t(I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC)}
</p>
<a
href={NEW_CONVERSATION_URL}
className="mt-2 inline-flex items-center gap-1 rounded-md bg-base px-3 py-2 text-xs font-medium text-content hover:bg-default transition-colors"
>
{t(I18nKey.AUTOMATIONS$EMPTY_START_CONVERSATION)}
<span aria-hidden="true">→</span>
</a>
</div>
</div>
{/* Documentation link */}
<p className="mt-4 text-center text-sm text-basic">
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-content transition-colors"
>
{t(I18nKey.AUTOMATIONS$EMPTY_LEARN_MORE)}
</a>
</p>
</>
);
if (collapsible) {
return (
<div className="w-full rounded-lg border border-default bg-base-secondary">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
className="flex w-full items-center justify-between p-4 text-left hover:bg-base transition-colors rounded-lg"
>
<span className="text-sm font-medium text-content">
{t(I18nKey.AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE)}
</span>
<ChevronDownIcon
className={`size-5 text-basic transition-transform ${
isExpanded ? "rotate-180" : ""
}`}
/>
</button>
{isExpanded && <div className="px-4 pb-4">{content}</div>}
</div>
);
}
return (
<div className="w-full max-w-2xl">
<h3 className="text-center text-sm font-medium text-content">
{t(I18nKey.AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE)}
</h3>
{content}
</div>
);
}
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60"
onClick={onCancel}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
role="presentation"
/>
<div className="relative w-full max-w-sm rounded-xl border border-neutral-700 bg-neutral-800 p-6">
<button
type="button"
onClick={onCancel}
className="absolute right-4 top-4 text-neutral-400 hover:text-white"
aria-label="Close"
>
<XMarkIcon className="size-5" />
</button>
<h2 className="text-lg font-semibold text-white">
{t(I18nKey.AUTOMATIONS$DELETE_CONFIRM_TITLE)}
</h2>
<p className="mt-2 text-sm text-neutral-400">
{t(I18nKey.AUTOMATIONS$DELETE_CONFIRM_MESSAGE, {
name: automationName,
})}
</p>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-neutral-600 px-4 py-2 text-sm text-white hover:bg-neutral-700"
>
{t(I18nKey.AUTOMATIONS$CANCEL)}
</button>
<button
type="button"
onClick={onConfirm}
className="rounded-lg bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700"
>
{t(I18nKey.AUTOMATIONS$DELETE)}
</button>
</div>
</div>
</div>
);
}
@@ -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 (
<span
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${
active
? "bg-success/20 text-success"
: "bg-neutral-700 text-neutral-400"
}`}
>
{active
? t(I18nKey.AUTOMATIONS$DETAIL$ACTIVE)
: t(I18nKey.AUTOMATIONS$DETAIL$INACTIVE)}
</span>
);
}
@@ -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 = (
<>
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-200">
{formatRunTimestamp(run.started_at, i18n.language)}
</span>
{!hasConversation && (
<span className="text-xs text-neutral-500 italic">
({t(I18nKey.AUTOMATIONS$DETAIL$NO_CONVERSATION)})
</span>
)}
</div>
<RunStatusBadge status={run.status} />
</>
);
if (hasConversation && run.conversation_id) {
return (
<a
href={getConversationUrl(run.conversation_id)}
className="flex items-center justify-between px-5 py-3 transition-colors cursor-pointer hover:bg-neutral-700/50 focus:bg-neutral-700/50 focus:outline-none"
aria-label={`View conversation for run at ${formatRunTimestamp(run.started_at, i18n.language)}`}
>
{content}
</a>
);
}
return (
<div className="flex items-center justify-between px-5 py-3 cursor-default">
{content}
</div>
);
}
@@ -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 (
<div className="rounded-2xl border border-neutral-700 bg-neutral-800">
<div className="flex items-center gap-2 border-b border-neutral-700 px-5 pb-3 pt-4">
<span className="size-4 text-neutral-400">
<ActivityIcon className="size-4" />
</span>
<h3 className="text-sm font-medium text-neutral-200">
{t(I18nKey.AUTOMATIONS$DETAIL$ACTIVITY_LOG)}
</h3>
</div>
{isLoading && (
<div className="space-y-1 p-5">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={`skeleton-${i}`}
className="flex items-center justify-between py-3"
>
<div className="h-5 w-64 animate-pulse rounded bg-neutral-700" />
<div className="h-6 w-24 animate-pulse rounded-full bg-neutral-700" />
</div>
))}
</div>
)}
{!isLoading && data && data.runs.length === 0 && (
<p className="px-5 py-8 text-center text-sm text-neutral-400">
{t(I18nKey.AUTOMATIONS$DETAIL$NO_RUNS)}
</p>
)}
{!isLoading && data && data.runs.length > 0 && (
<div>
{data.runs.map((run, index) => (
<div
key={run.id}
className={index > 0 ? "border-t border-neutral-700" : ""}
>
<ActivityLogItem run={run} />
</div>
))}
{hasMore && (
<div className="border-t border-neutral-700 px-5 py-3">
<button
type="button"
onClick={() => setLimit((prev) => prev + PAGE_SIZE)}
className="text-sm text-neutral-400 hover:text-neutral-200"
>
{t(I18nKey.AUTOMATIONS$DETAIL$LOAD_MORE_RUNS)}
</button>
</div>
)}
</div>
)}
</div>
);
}
@@ -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, unknown>) => 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 (
<SectionCard
icon={<ActivityIcon className="size-4" />}
title={t(I18nKey.AUTOMATIONS$DETAIL$ACTIVITY)}
>
<div className="grid grid-cols-2 gap-x-4">
<ConfigField
icon={<CalendarIcon className="size-3.5" />}
label={t(I18nKey.AUTOMATIONS$DETAIL$CREATED)}
>
{formatDate(createdAt, locale)}
</ConfigField>
<ConfigField
icon={<ClockIcon className="size-3.5" />}
label={t(I18nKey.AUTOMATIONS$DETAIL$LAST_RUN)}
>
{lastRunAt
? formatRelativeTime(lastRunAt, locale, t)
: t(I18nKey.AUTOMATIONS$DETAIL$TIME_NEVER)}
</ConfigField>
</div>
</SectionCard>
);
}
@@ -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 (
<NavigationLink
to="/automations"
className="inline-flex items-center gap-1.5 text-sm text-neutral-400 hover:text-neutral-200"
>
<ChevronLeftIcon className="size-4" />
{t(I18nKey.AUTOMATIONS$DETAIL$BACK_TO_LIST)}
</NavigationLink>
);
}
@@ -0,0 +1,11 @@
interface BranchBadgeProps {
branch: string;
}
export function BranchBadge({ branch }: BranchBadgeProps) {
return (
<span className="inline-flex items-center rounded-full border border-neutral-600 bg-neutral-700/50 px-2.5 py-0.5 text-xs text-neutral-400">
{branch}
</span>
);
}
@@ -0,0 +1,17 @@
interface ConfigFieldProps {
icon: React.ReactNode;
label: string;
children: React.ReactNode;
}
export function ConfigField({ icon, label, children }: ConfigFieldProps) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="size-3.5 text-neutral-400">{icon}</span>
<span className="text-xs font-medium text-neutral-400">{label}</span>
</div>
<div className="text-sm text-neutral-200">{children}</div>
</div>
);
}
@@ -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 (
<SectionCard
icon={<CogIcon className="size-4" />}
title={t(I18nKey.AUTOMATIONS$DETAIL$CONFIGURATION)}
>
<div className="grid grid-cols-2 gap-x-4 gap-y-5">
{automation.repository && (
<ConfigField
icon={<GitBranchIcon className="size-3.5" />}
label={t(I18nKey.AUTOMATIONS$DETAIL$REPOSITORIES)}
>
<span className="flex items-center gap-1">
{automation.repository}
{automation.branch && <BranchBadge branch={automation.branch} />}
</span>
</ConfigField>
)}
<ConfigField
icon={<CheckCircleIcon className="size-3.5" />}
label={t(I18nKey.AUTOMATIONS$DETAIL$TRIGGER)}
>
{triggerDisplay}
</ConfigField>
<ConfigField
icon={<CalendarIcon className="size-3.5" />}
label={t(I18nKey.AUTOMATIONS$DETAIL$SCHEDULE)}
>
{scheduleDisplay}
</ConfigField>
{automation.model && (
<ConfigField
icon={<SparkleIcon className="size-3.5" />}
label={t(I18nKey.AUTOMATIONS$DETAIL$MODEL)}
>
{automation.model}
</ConfigField>
)}
{automation.notification && (
<ConfigField
icon={<BellIcon className="size-3.5" />}
label={t(I18nKey.AUTOMATIONS$DETAIL$NOTIFICATION)}
>
{automation.notification}
</ConfigField>
)}
</div>
</SectionCard>
);
}
@@ -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: <PowerIcon className="size-4" />,
onClick: onToggle,
},
{
label: t(I18nKey.AUTOMATIONS$DELETE),
icon: <TrashIcon className="size-4" />,
onClick: onDelete,
variant: "danger" as const,
},
];
return (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-neutral-100">
{automation.name}
</h1>
<ActiveStatusBadge active={automation.enabled} />
</div>
<div className="flex items-center gap-2">
{canManage && (
<ToggleSwitch
enabled={automation.enabled}
label={
automation.enabled
? t(I18nKey.AUTOMATIONS$TURN_OFF)
: t(I18nKey.AUTOMATIONS$TURN_ON)
}
onToggle={onToggle}
/>
)}
{canManage && <KebabMenu items={kebabItems} />}
</div>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
function SkeletonBlock({ className }: { className: string }) {
return (
<div className={`animate-pulse rounded bg-neutral-700 ${className}`} />
);
}
export function DetailSkeleton() {
return (
<div className="flex flex-col gap-4" data-testid="detail-skeleton">
<SkeletonBlock className="h-5 w-40" />
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-6 w-48" />
<SkeletonBlock className="h-6 w-16 rounded-full" />
</div>
<div className="flex items-center gap-2">
<SkeletonBlock className="h-6 w-11 rounded-full" />
<SkeletonBlock className="h-8 w-8" />
</div>
</div>
<SkeletonBlock className="h-5 w-96" />
<SkeletonBlock className="h-36 w-full rounded-2xl" />
<SkeletonBlock className="h-72 w-full rounded-2xl" />
<SkeletonBlock className="h-32 w-full rounded-2xl" />
<SkeletonBlock className="h-40 w-full rounded-2xl" />
</div>
);
}
@@ -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 (
<div className="flex flex-col items-center justify-center py-20">
<ExclamationCircleIcon className="size-12 text-neutral-400" />
<p className="mt-4 text-sm font-medium text-neutral-200">
{t(I18nKey.AUTOMATIONS$DETAIL$NOT_FOUND_TITLE)}
</p>
<p className="mt-2 text-sm text-neutral-400">
{t(I18nKey.AUTOMATIONS$DETAIL$NOT_FOUND_MESSAGE)}
</p>
<div className="mt-6">
<BackLink />
</div>
</div>
);
}
@@ -0,0 +1,11 @@
interface PluginChipProps {
name: string;
}
export function PluginChip({ name }: PluginChipProps) {
return (
<span className="inline-flex items-center rounded-full border border-neutral-600 bg-neutral-700/30 px-3.5 py-1.5 text-sm text-neutral-200">
{name}
</span>
);
}
@@ -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 (
<SectionCard
icon={<PuzzleIcon className="size-4" />}
title={t(I18nKey.AUTOMATIONS$DETAIL$PLUGINS)}
>
<div className="flex flex-wrap gap-2">
{plugins.map((plugin) => (
<PluginChip key={plugin} name={plugin} />
))}
</div>
</SectionCard>
);
}
@@ -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 (
<SectionCard
icon={<TerminalIcon className="size-4" />}
title={t(I18nKey.AUTOMATIONS$DETAIL$PROMPT)}
>
<div className="rounded-xl bg-neutral-900/50">
<p className="whitespace-pre-wrap text-sm leading-6 text-neutral-200">
{prompt}
</p>
</div>
</SectionCard>
);
}
@@ -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 <CheckCircleIcon className="size-3.5" />;
case AutomationRunStatus.FAILED:
return <XCircleIcon className="size-3.5" />;
default:
return <ClockIcon className="size-3.5" />;
}
}
export function RunStatusBadge({ status }: RunStatusBadgeProps) {
const { t } = useTranslation("openhands");
const config = statusConfig[status];
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${config.style}`}
>
<StatusIcon status={status} />
{t(config.label)}
</span>
);
}
@@ -0,0 +1,17 @@
interface SectionCardProps {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}
export function SectionCard({ icon, title, children }: SectionCardProps) {
return (
<div className="rounded-2xl border border-neutral-700 bg-neutral-800">
<div className="flex items-center gap-2 border-b border-neutral-700 px-5 pb-3 pt-4">
<span className="size-4 text-neutral-400">{icon}</span>
<h3 className="text-sm font-medium text-neutral-200">{title}</h3>
</div>
<div className="px-5 py-5">{children}</div>
</div>
);
}
@@ -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 (
<div className="flex flex-col items-center justify-center py-12">
<DatabaseIcon className="size-12 text-neutral-500" />
<p className="mt-4 text-sm text-neutral-400">
{t(I18nKey.AUTOMATIONS$EMPTY)}
</p>
{/* How to create section */}
<div className="mt-8 flex justify-center">
<CreateInstructions />
</div>
</div>
);
}
@@ -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 (
<div className="flex flex-col items-center justify-center py-20">
<ExclamationCircleIcon className="size-12 text-red-400" />
<p className="mt-4 text-sm text-neutral-400">
{t(I18nKey.AUTOMATIONS$ERROR_TITLE)}
</p>
<button
type="button"
onClick={onRetry}
className="mt-4 rounded-lg border border-neutral-600 px-4 py-2 text-sm text-white hover:bg-neutral-700"
>
{t(I18nKey.AUTOMATIONS$ERROR_RETRY)}
</button>
</div>
);
}
@@ -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<HTMLDivElement>(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 (
<div ref={menuRef} className="relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(!open);
}}
className="flex size-8 cursor-pointer items-center justify-center rounded border border-neutral-600 bg-neutral-800 hover:bg-neutral-700"
aria-label="Automation actions"
>
<KebabVerticalIcon className="size-4 text-neutral-400" />
</button>
{open && (
<div className="absolute right-0 top-full z-10 mt-1 w-36 rounded-lg border border-neutral-600 bg-neutral-800 py-1 shadow-lg">
{items.map((item) => (
<button
key={item.label}
type="button"
onClick={(e) => {
e.stopPropagation();
item.onClick();
setOpen(false);
}}
className={`flex w-full cursor-pointer items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-700 ${
item.variant === "danger" ? "text-red-400" : "text-white"
}`}
>
{item.icon}
{item.label}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,13 @@
interface MetadataChipProps {
icon: React.ReactNode;
label: string;
}
export function MetadataChip({ icon, label }: MetadataChipProps) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-neutral-600 px-3 py-1 text-xs text-neutral-400">
{icon}
{label}
</span>
);
}
@@ -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 (
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-neutral-400" />
<input
type="text"
value={value}
onChange={(e) => 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"
/>
</div>
);
}
@@ -0,0 +1,11 @@
interface StatusBadgeProps {
count: number;
}
export function StatusBadge({ count }: StatusBadgeProps) {
return (
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-neutral-700 px-2 py-0.5 text-xs text-white">
{count}
</span>
);
}
@@ -0,0 +1,33 @@
interface ToggleSwitchProps {
enabled: boolean;
label: string;
onToggle: () => void;
}
export function ToggleSwitch({ enabled, label, onToggle }: ToggleSwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={label}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
className={`relative inline-flex h-[22px] w-[40px] shrink-0 cursor-pointer items-center rounded-full border transition-colors ${
enabled
? "border-success bg-success/20"
: "border-neutral-600 bg-neutral-700"
}`}
>
<span
className={`inline-block size-4 rounded-full transition-transform ${
enabled
? "translate-x-[20px] bg-success"
: "translate-x-[3px] bg-neutral-400"
}`}
/>
</button>
);
}
+23
View File
@@ -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,
});
}
+35
View File
@@ -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 });
},
});
}
+6
View File
@@ -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;
}
+212
View File
@@ -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": "会話",
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182" />
</svg>

After

Width:  |  Height:  |  Size: 336 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>

After

Width:  |  Height:  |  Size: 396 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>

After

Width:  |  Height:  |  Size: 244 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>

After

Width:  |  Height:  |  Size: 208 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>

After

Width:  |  Height:  |  Size: 209 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>

After

Width:  |  Height:  |  Size: 225 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>

After

Width:  |  Height:  |  Size: 541 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>

After

Width:  |  Height:  |  Size: 256 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 3v12m0 0a3 3 0 1 0 3 3M6 15a3 3 0 0 1 3-3h6a3 3 0 0 0 3-3V3m0 0a3 3 0 1 0 0 0" />
<circle cx="6" cy="3" r="2" />
<circle cx="18" cy="3" r="2" />
<circle cx="9" cy="18" r="2" />
</svg>

After

Width:  |  Height:  |  Size: 363 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z" />
</svg>

After

Width:  |  Height:  |  Size: 192 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>

After

Width:  |  Height:  |  Size: 218 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 0 1-.657.643 48.39 48.39 0 0 1-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 0 1-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 0 0-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 0 1-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 0 0 .657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 0 1-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 0 0 5.427-.63 48.05 48.05 0 0 0 .582-4.717.532.532 0 0 0-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 0 0 .658-.663 48.422 48.422 0 0 0-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 0 1-.61-.58v0Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M11 19a8 8 0 100-16 8 8 0 000 16z" />
</svg>

After

Width:  |  Height:  |  Size: 232 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
</svg>

After

Width:  |  Height:  |  Size: 568 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>

After

Width:  |  Height:  |  Size: 244 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>

After

Width:  |  Height:  |  Size: 306 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>

After

Width:  |  Height:  |  Size: 251 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>

After

Width:  |  Height:  |  Size: 202 B

+123
View File
@@ -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<string, Automation>(
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<Automation>;
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 });
}),
];
+61
View File
@@ -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<string, AutomationRun[]> = {
"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": [],
};
+127
View File
@@ -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,
};
+10 -1
View File
@@ -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,
};
+2
View File
@@ -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",
+108
View File
@@ -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 (
<div className="p-6 max-w-4xl mx-auto">
<DetailSkeleton />
</div>
);
}
if (is404) {
return (
<div className="p-6 max-w-4xl mx-auto">
<NotFoundState />
</div>
);
}
if (isError || !automation) {
return (
<div className="p-6 max-w-4xl mx-auto">
<ErrorState onRetry={() => refetch()} />
</div>
);
}
const handleToggle = () => {
toggleMutation.mutate({
id: automation.id,
enabled: !automation.enabled,
});
};
const handleDelete = () => {
deleteMutation.mutate(automation.id, {
onSuccess: () => {
navigate?.("/automations");
},
});
};
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="flex flex-col gap-4">
<BackLink />
<DetailHeader
automation={automation}
onToggle={handleToggle}
onDelete={() => setShowDeleteModal(true)}
/>
{automation.prompt && <PromptSection prompt={automation.prompt} />}
<ConfigurationSection automation={automation} />
{automation.plugins && automation.plugins.length > 0 && (
<PluginsSection plugins={automation.plugins} />
)}
<ActivitySection
createdAt={automation.created_at}
lastRunAt={automation.last_triggered_at}
/>
<ActivityLogSection automationId={automation.id} />
<DeleteConfirmationModal
automationName={automation.name}
isOpen={showDeleteModal}
onConfirm={handleDelete}
onCancel={() => setShowDeleteModal(false)}
/>
</div>
</div>
);
}
+144
View File
@@ -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 (
<div className="p-6 max-w-4xl mx-auto">
{/* Header */}
<h1 className="text-xl font-semibold text-neutral-100">
{t(I18nKey.AUTOMATIONS$TITLE)}
</h1>
<p className="mt-1 text-sm text-neutral-400">
{t(I18nKey.AUTOMATIONS$SUBTITLE)}
</p>
{/* Search */}
<div className="mt-6">
<SearchInput value={searchQuery} onChange={setSearchQuery} />
</div>
{/* Content */}
<div className="mt-6 flex flex-col gap-6">
{isLoading && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<AutomationCardSkeleton key={`skeleton-${String(i)}`} />
))}
</div>
)}
{isError && !isLoading && <ErrorState onRetry={refetch} />}
{!isLoading && !isError && data && data.automations.length === 0 && (
<EmptyState />
)}
{!isLoading && !isError && data && data.automations.length > 0 && (
<>
{/* Collapsible creation instructions at the top */}
<CreateInstructions collapsible />
<AutomationGroup
title={t(I18nKey.AUTOMATIONS$ACTIVE)}
count={active.length}
automations={active}
onToggle={handleToggle}
onDelete={handleDeleteRequest}
/>
<AutomationGroup
title={t(I18nKey.AUTOMATIONS$INACTIVE)}
count={inactive.length}
automations={inactive}
onToggle={handleToggle}
onDelete={handleDeleteRequest}
/>
{hasMore && (
<button
type="button"
onClick={() => setLimit((prev) => prev + PAGE_SIZE)}
className="self-center rounded-lg border border-neutral-600 px-6 py-2 text-sm text-white hover:bg-neutral-700"
>
{t(I18nKey.AUTOMATIONS$LOAD_MORE)}
</button>
)}
</>
)}
</div>
{/* Delete confirmation modal */}
<DeleteConfirmationModal
automationName={deleteTarget?.name ?? ""}
isOpen={deleteTarget !== null}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}
+48
View File
@@ -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;
}
+6 -2
View File
@@ -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 = (