mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 17:28:03 +08:00
refactor: serve the automation interface from the manifest alone (#16676)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -37,18 +37,22 @@ vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// The pinned package may publish an interface manifest whose literal copy
|
||||
// replaces the host's translations. Pin the candidate to "not published" so
|
||||
// these tests exercise the host defaults whatever the package ships.
|
||||
vi.mock("#/manifests/manifest-sources", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("#/manifests/manifest-sources")>()),
|
||||
AUTOMATION_INTERFACE_CANDIDATE: undefined,
|
||||
}));
|
||||
// The form is the interface manifest's, so these tests run against the
|
||||
// manifest the pinned package publishes — the one a user meets.
|
||||
vi.mock("#/manifests/manifest-sources", async (importOriginal) => {
|
||||
const extensions = await import("@openhands/extensions/automations");
|
||||
return {
|
||||
...(await importOriginal<typeof import("#/manifests/manifest-sources")>()),
|
||||
AUTOMATION_INTERFACE_CANDIDATE: (
|
||||
extensions as { AUTOMATION_INTERFACE?: unknown }
|
||||
).AUTOMATION_INTERFACE,
|
||||
};
|
||||
});
|
||||
|
||||
// The interface seam resolves its manifest once at module load, so the
|
||||
// manifest-driven test overrides individual attribute specs here instead of
|
||||
// installing a whole manifest. Empty overrides leave the host defaults —
|
||||
// today's form — in force for every other test.
|
||||
// installing a whole manifest. Empty overrides leave the published
|
||||
// manifest's form in force for every other test.
|
||||
const specOverrides = vi.hoisted(() => ({
|
||||
current: {} as Record<string, object>,
|
||||
}));
|
||||
@@ -298,13 +302,11 @@ describe("EditAutomationModal", () => {
|
||||
// The picker pre-fills with the automation's current profile once the
|
||||
// available profiles have loaded.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("AUTOMATIONS$DETAIL$MODEL")).toHaveValue(
|
||||
"fast",
|
||||
),
|
||||
expect(screen.getByLabelText("LLM profile")).toHaveValue("fast"),
|
||||
);
|
||||
|
||||
// Act — switch to "careful" and save.
|
||||
await user.click(screen.getByLabelText("AUTOMATIONS$DETAIL$MODEL"));
|
||||
await user.click(screen.getByLabelText("LLM profile"));
|
||||
await user.click(await screen.findByText("careful"));
|
||||
await user.click(screen.getByTestId("edit-automation-save"));
|
||||
|
||||
@@ -331,13 +333,11 @@ describe("EditAutomationModal", () => {
|
||||
|
||||
// The picker pre-fills with the pinned profile once profiles have loaded.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("AUTOMATIONS$DETAIL$MODEL")).toHaveValue(
|
||||
"fast",
|
||||
),
|
||||
expect(screen.getByLabelText("LLM profile")).toHaveValue("fast"),
|
||||
);
|
||||
|
||||
// Act — clear the pin via the "Active profile" option, then save.
|
||||
await user.click(screen.getByLabelText("AUTOMATIONS$DETAIL$MODEL"));
|
||||
await user.click(screen.getByLabelText("LLM profile"));
|
||||
await user.click(await screen.findByText("COMMON$ACTIVE_PROFILE"));
|
||||
await user.click(screen.getByTestId("edit-automation-save"));
|
||||
|
||||
@@ -360,7 +360,7 @@ describe("EditAutomationModal", () => {
|
||||
renderModal(modeledAutomation);
|
||||
|
||||
// Ensure we're on the profiles-available path before editing.
|
||||
await screen.findByLabelText("AUTOMATIONS$DETAIL$MODEL");
|
||||
await screen.findByLabelText("LLM profile");
|
||||
|
||||
// Act — change only the name; leave the profile on "fast".
|
||||
const nameInput = screen.getByTestId("edit-automation-name");
|
||||
@@ -384,9 +384,7 @@ describe("EditAutomationModal", () => {
|
||||
|
||||
// Assert — once the (empty) profile list resolves, no picker is offered.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByLabelText("AUTOMATIONS$DETAIL$MODEL"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("LLM profile")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createCommandMenuItems } from "#/components/features/command-menu/command-menu-items";
|
||||
|
||||
/**
|
||||
* The automation entry's title, description, and keywords are the interface
|
||||
* manifest's. Without one there is no copy to show and no surface to reach,
|
||||
* so the entry is not offered at all.
|
||||
*/
|
||||
vi.mock("#/manifests/manifest-sources", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("#/manifests/manifest-sources")>();
|
||||
return { ...actual, AUTOMATION_INTERFACE_CANDIDATE: undefined };
|
||||
});
|
||||
|
||||
describe("the command menu without an admitted interface manifest", () => {
|
||||
it("omits the automations entry and keeps the rest", () => {
|
||||
// Act
|
||||
const items = createCommandMenuItems({ toggleSidebar: vi.fn() });
|
||||
|
||||
// Assert
|
||||
expect(items.some((item) => item.id === "automations")).toBe(false);
|
||||
expect(items.some((item) => item.id === "new-chat")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,18 +12,22 @@ import { renderWithProviders } from "../../../../test-utils";
|
||||
|
||||
const OPEN_LABEL_KEY = "COMMAND_MENU$OPEN_LABEL";
|
||||
const SEARCH_LABEL_KEY = "COMMAND_MENU$SEARCH_LABEL";
|
||||
const AUTOMATIONS_TITLE_KEY = "COMMAND_MENU$AUTOMATIONS_TITLE";
|
||||
// The automation entry's copy is the interface manifest's, never a key.
|
||||
const AUTOMATIONS_TITLE = "Automation center";
|
||||
const NEW_CHAT_TITLE_KEY = "COMMAND_MENU$NEW_CHAT_TITLE";
|
||||
const SECRETS_TITLE_KEY = "COMMAND_MENU$SECRETS_SETTINGS_TITLE";
|
||||
const TOGGLE_SIDEBAR_TITLE_KEY = "COMMAND_MENU$TOGGLE_SIDEBAR_TITLE";
|
||||
|
||||
// The pinned package may publish an interface manifest whose literal copy
|
||||
// replaces the host's translations. Pin the candidate to "not published" so
|
||||
// these tests exercise the host defaults whatever the package ships.
|
||||
vi.mock("#/manifests/manifest-sources", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("#/manifests/manifest-sources")>()),
|
||||
AUTOMATION_INTERFACE_CANDIDATE: undefined,
|
||||
}));
|
||||
// Pin the published interface manifest to the factory one, so the automation
|
||||
// entry's copy is known whatever the pinned package ships.
|
||||
vi.mock("#/manifests/manifest-sources", async (importOriginal) => {
|
||||
const { createInterfaceManifest } =
|
||||
await import("../../../manifests/manifest-test-data");
|
||||
return {
|
||||
...(await importOriginal<typeof import("#/manifests/manifest-sources")>()),
|
||||
AUTOMATION_INTERFACE_CANDIDATE: createInterfaceManifest(),
|
||||
};
|
||||
});
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
@@ -89,7 +93,7 @@ describe("CommandMenu", () => {
|
||||
useCommandMenuStore.getState().open();
|
||||
const { navigate } = renderCommandMenu();
|
||||
|
||||
await userEvent.click(screen.getByText(AUTOMATIONS_TITLE_KEY));
|
||||
await userEvent.click(screen.getByText(AUTOMATIONS_TITLE));
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(COMMAND_MENU_ROUTE.automations);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -22,49 +22,39 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("the automation interface seam", () => {
|
||||
it("serves the host defaults when the package publishes no manifest", async () => {
|
||||
it("has no interface at all when the package publishes no manifest", async () => {
|
||||
// Arrange — the pinned package predates the interface export.
|
||||
const seam = await loadSeam(undefined);
|
||||
|
||||
// Act & Assert — copy defers to host i18n; every other datum is the
|
||||
// host's own literal, byte-identical to pre-seam behavior.
|
||||
// Act & Assert — the gate is closed, and the accessors behind it refuse
|
||||
// rather than invent copy the host does not own. Routes are the host's
|
||||
// own registrations, so they answer either way.
|
||||
expect(seam.hasAutomationInterface()).toBe(false);
|
||||
expect(() => seam.getInterfaceCopy()).toThrow(
|
||||
/No automation interface manifest is admitted/,
|
||||
);
|
||||
expect(() => seam.getAutomationEndpoint("createPrompt")).toThrow();
|
||||
expect(() => seam.getAttributeSpec("timeout")).toThrow();
|
||||
expect(() => seam.getImportExportSpec()).toThrow();
|
||||
expect(() => seam.getFeaturedAutomationIds()).toThrow();
|
||||
expect({
|
||||
listTitle: seam.getInterfaceCopy().listTitle,
|
||||
sidebarLabel: seam.getInterfaceCopy().sidebarLabel,
|
||||
listPath: seam.automationListPath(),
|
||||
setupPath: seam.automationSetupPath("github-pr-reviewer"),
|
||||
detailPath: seam.automationDetailPath("a/b"),
|
||||
createEndpoint: seam.getAutomationEndpoint("createPrompt"),
|
||||
dispatchEndpoint: seam.getAutomationIdEndpoint("dispatch", "id-1"),
|
||||
timeoutMax: seam.getAttributeSpec("timeout").max,
|
||||
filenameSuffix: seam.getImportExportSpec().filenameSuffix,
|
||||
featured: [...seam.getFeaturedAutomationIds()],
|
||||
responders: [...seam.getResponderIntegrationIds()],
|
||||
}).toEqual({
|
||||
listTitle: null,
|
||||
sidebarLabel: null,
|
||||
listPath: "/automations",
|
||||
setupPath: "/automations/new/github-pr-reviewer",
|
||||
detailPath: "/automations/a%2Fb",
|
||||
createEndpoint: "/v1/preset/prompt",
|
||||
dispatchEndpoint: "/v1/id-1/dispatch",
|
||||
timeoutMax: null,
|
||||
filenameSuffix: ".automation.json",
|
||||
featured: [
|
||||
"github-pr-reviewer",
|
||||
"github-repo-monitor",
|
||||
"slack-channel-monitor",
|
||||
],
|
||||
responders: ["github", "slack"],
|
||||
});
|
||||
});
|
||||
|
||||
it("serves an admitted manifest's data instead of the defaults", async () => {
|
||||
it("serves an admitted manifest's data", async () => {
|
||||
// Arrange
|
||||
const seam = await loadSeam(createInterfaceManifest());
|
||||
|
||||
// Act & Assert — copy, attribute specs, the import/export envelope, and
|
||||
// the id lists all come from the manifest.
|
||||
expect(seam.hasAutomationInterface()).toBe(true);
|
||||
expect({
|
||||
listTitle: seam.getInterfaceCopy().listTitle,
|
||||
sidebarLabel: seam.getInterfaceCopy().sidebarLabel,
|
||||
@@ -169,21 +159,19 @@ describe("the automation interface seam", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the defaults, loudly, when a manifest fails admission", async () => {
|
||||
it("stays unavailable, loudly, when a manifest fails admission", async () => {
|
||||
// Arrange
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const seam = await loadSeam(
|
||||
createInterfaceManifest({ version: "2.0" as "1.0" }),
|
||||
);
|
||||
|
||||
// Act
|
||||
const copy = seam.getInterfaceCopy();
|
||||
|
||||
// Assert — nothing from the rejected manifest leaks through, and the
|
||||
// rejection is reported rather than silent.
|
||||
// Assert — a rejected manifest leaves the surface closed, exactly as an
|
||||
// absent one does, and the rejection is reported rather than silent.
|
||||
expect({
|
||||
listTitle: copy.listTitle,
|
||||
available: seam.hasAutomationInterface(),
|
||||
warned: warn.mock.calls.length,
|
||||
}).toEqual({ listTitle: null, warned: 1 });
|
||||
}).toEqual({ available: false, warned: 1 });
|
||||
expect(() => seam.getInterfaceCopy()).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import incidentFixture from "@openhands/extensions/testing/automations/incident-
|
||||
import prReviewerFixture from "@openhands/extensions/testing/automations/github-pr-reviewer.json";
|
||||
import repoMonitorFixture from "@openhands/extensions/testing/automations/github-repo-monitor.json";
|
||||
import {
|
||||
AUTOMATION_CREATE_ENDPOINT,
|
||||
automationCreateEndpoint,
|
||||
buildAssistedMessage,
|
||||
buildCreatePayload,
|
||||
buildPreflightBody,
|
||||
@@ -177,9 +177,13 @@ describe("the contract fixtures", () => {
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect({ create: [...createPaths], preflight: [...preflightPaths] }).toEqual(
|
||||
{ create: [AUTOMATION_CREATE_ENDPOINT], preflight: ["/v1/validate"] },
|
||||
);
|
||||
expect({
|
||||
create: [...createPaths],
|
||||
preflight: [...preflightPaths],
|
||||
}).toEqual({
|
||||
create: [automationCreateEndpoint()],
|
||||
preflight: ["/v1/validate"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Without an admitted interface manifest there is no automation surface to
|
||||
* render — the pages exist only to render what a manifest describes. Each
|
||||
* route says so the way the host says it everywhere else: a 404 the layout's
|
||||
* error boundary renders.
|
||||
*/
|
||||
vi.mock("#/manifests/manifest-sources", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("#/manifests/manifest-sources")>();
|
||||
return { ...actual, AUTOMATION_INTERFACE_CANDIDATE: undefined };
|
||||
});
|
||||
|
||||
function statusOfLoader(load: () => unknown): number | null {
|
||||
try {
|
||||
load();
|
||||
} catch (error) {
|
||||
return error instanceof Response ? error.status : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("the automation routes without an admitted interface manifest", () => {
|
||||
it("404s every automation route", async () => {
|
||||
// Arrange
|
||||
const [list, detail, setup, templates] = await Promise.all([
|
||||
import("#/routes/automations-list"),
|
||||
import("#/routes/automation-detail"),
|
||||
import("#/routes/automation-setup-route"),
|
||||
import("#/routes/automation-templates"),
|
||||
]);
|
||||
|
||||
// Act & Assert
|
||||
expect({
|
||||
list: statusOfLoader(() => list.clientLoader()),
|
||||
detail: statusOfLoader(() => detail.clientLoader()),
|
||||
setup: statusOfLoader(() =>
|
||||
setup.clientLoader({
|
||||
params: { automationId: "github-pr-reviewer" },
|
||||
} as Parameters<typeof setup.clientLoader>[0]),
|
||||
),
|
||||
templates: statusOfLoader(() => templates.clientLoader()),
|
||||
}).toEqual({ list: 404, detail: 404, setup: 404, templates: 404 });
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,18 @@ import { clientLoader as templatesLoader } from "#/routes/automation-templates";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import type { Automation } from "#/types/automation";
|
||||
|
||||
// Pin the published data source to "no interface manifest", independent of
|
||||
// whatever the pinned package ships: the sub-page surface must stay dark.
|
||||
// Pin the published data source to a manifest that declares no sub-page
|
||||
// surface, independent of whatever the pinned package ships: the interface
|
||||
// exists, so the page renders, but the sub-page surface must stay dark.
|
||||
vi.mock("#/manifests/manifest-sources", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("#/manifests/manifest-sources")>();
|
||||
return { ...actual, AUTOMATION_INTERFACE_CANDIDATE: undefined };
|
||||
const { createInterfaceManifest } =
|
||||
await import("../manifests/manifest-test-data");
|
||||
return {
|
||||
...actual,
|
||||
AUTOMATION_INTERFACE_CANDIDATE: createInterfaceManifest(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/api/automation-service/automation-service.api", () => ({
|
||||
@@ -88,7 +94,7 @@ afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("the sub-page surface without a declared manifest", () => {
|
||||
describe("an interface manifest that declares no sub-page surface", () => {
|
||||
it("keeps today's plain list: launcher on the page, no dashboard chrome", async () => {
|
||||
// Arrange & Act
|
||||
renderList();
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
GitSyncStatus,
|
||||
GitSyncTriggerResponse,
|
||||
} from "#/types/git-sync";
|
||||
import { AUTOMATION_CREATE_ENDPOINT } from "#/manifests/automation-setup";
|
||||
import { automationCreateEndpoint } from "#/manifests/automation-setup";
|
||||
import {
|
||||
getAutomationEndpoint,
|
||||
getAutomationIdEndpoint,
|
||||
@@ -587,7 +587,7 @@ class AutomationService {
|
||||
body: SetupRequestBody,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const active = getActiveBackend().backend;
|
||||
const path = `${AUTOMATION_BASE_PATH}${AUTOMATION_CREATE_ENDPOINT}`;
|
||||
const path = `${AUTOMATION_BASE_PATH}${automationCreateEndpoint()}`;
|
||||
|
||||
if (active.kind === "cloud") {
|
||||
return callCloudProxy<Record<string, unknown>>({
|
||||
|
||||
@@ -10,8 +10,6 @@ import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { getAutomationsDocsUrl } from "#/manifests/automation-interface";
|
||||
|
||||
const DOCS_URL = getAutomationsDocsUrl();
|
||||
|
||||
function InlineExampleWrap({ children }: { children?: ReactNode }) {
|
||||
return <span className="whitespace-nowrap">{children}</span>;
|
||||
}
|
||||
@@ -75,7 +73,7 @@ export function CreateInstructionsContent({
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
href={getAutomationsDocsUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted underline transition-colors hover:text-foreground"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { MANIFEST_ICON_BY_SLUG } from "#/components/features/manifest/manifest-icons";
|
||||
import type { SubPageNavItem } from "#/components/features/manifest/manifest-subpage-layout";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
@@ -20,14 +18,13 @@ export interface AutomationSubPageNav {
|
||||
* there.
|
||||
*/
|
||||
export function useAutomationSubPageNav(): AutomationSubPageNav | null {
|
||||
const { t } = useTranslation("openhands");
|
||||
const active = useActiveBackend();
|
||||
const spec = getSubPagesSpec();
|
||||
if (!spec) return null;
|
||||
|
||||
const isCloudBackend = active.backend.kind === "cloud";
|
||||
return {
|
||||
heading: getInterfaceCopy().sidebarLabel ?? t(I18nKey.SIDEBAR$AUTOMATIONS),
|
||||
heading: getInterfaceCopy().sidebarLabel,
|
||||
items: spec
|
||||
.filter((item) => !(item.page === "templates" && isCloudBackend))
|
||||
.map((item) => ({
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BackNavButton } from "#/components/shared/buttons/back-nav-button";
|
||||
import {
|
||||
automationListPath,
|
||||
@@ -7,12 +5,9 @@ import {
|
||||
} from "#/manifests/automation-interface";
|
||||
|
||||
export function BackLink() {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
return (
|
||||
<BackNavButton to={automationListPath()}>
|
||||
{getInterfaceCopy().detailBackLabel ??
|
||||
t(I18nKey.AUTOMATIONS$DETAIL$BACK_TO_LIST)}
|
||||
{getInterfaceCopy().detailBackLabel}
|
||||
</BackNavButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,8 +127,7 @@ export function EditAutomationModal({
|
||||
const updateMutation = useUpdateAutomation();
|
||||
// Which attributes the dialog offers, and their copy, come from the
|
||||
// interface manifest; absent one, the host defaults reproduce today's form.
|
||||
const editTitle =
|
||||
getInterfaceCopy().editTitle ?? t(I18nKey.AUTOMATIONS$EDIT_TITLE);
|
||||
const editTitle = getInterfaceCopy().editTitle;
|
||||
const nameSpec = getAttributeSpec("name");
|
||||
const promptSpec = getAttributeSpec("prompt");
|
||||
const modelSpec = getAttributeSpec("model");
|
||||
@@ -308,7 +307,7 @@ export function EditAutomationModal({
|
||||
testId="edit-automation-name"
|
||||
name="name"
|
||||
type="text"
|
||||
label={nameSpec.label ?? t(I18nKey.AUTOMATIONS$NAME)}
|
||||
label={nameSpec.label}
|
||||
value={form.name}
|
||||
onChange={(value) => setForm((f) => ({ ...f, name: value }))}
|
||||
error={nameError ?? undefined}
|
||||
@@ -318,9 +317,7 @@ export function EditAutomationModal({
|
||||
|
||||
{promptSpec.present && (
|
||||
<label className="flex flex-col gap-2.5 w-full min-w-0">
|
||||
<span className="text-sm">
|
||||
{promptSpec.label ?? t(I18nKey.AUTOMATIONS$PROMPT)}
|
||||
</span>
|
||||
<span className="text-sm">{promptSpec.label}</span>
|
||||
<textarea
|
||||
data-testid="edit-automation-prompt"
|
||||
name="prompt"
|
||||
@@ -334,9 +331,9 @@ export function EditAutomationModal({
|
||||
"placeholder:italic",
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-muted">
|
||||
{promptSpec.help ?? t(I18nKey.AUTOMATIONS$EDIT_PROMPT_HINT)}
|
||||
</span>
|
||||
{promptSpec.help !== null && (
|
||||
<span className="text-xs text-muted">{promptSpec.help}</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -344,7 +341,7 @@ export function EditAutomationModal({
|
||||
<SettingsDropdownInput
|
||||
testId="edit-automation-model"
|
||||
name="model"
|
||||
label={modelSpec.label ?? t(I18nKey.AUTOMATIONS$DETAIL$MODEL)}
|
||||
label={modelSpec.label}
|
||||
items={modelItems}
|
||||
selectedKey={form.model || ACTIVE_PROFILE_KEY}
|
||||
isLoading={isLoadingProfiles}
|
||||
@@ -364,7 +361,7 @@ export function EditAutomationModal({
|
||||
testId="edit-automation-timeout"
|
||||
name="timeout"
|
||||
type="number"
|
||||
label={timeoutSpec.label ?? t(I18nKey.AUTOMATIONS$TIMEOUT)}
|
||||
label={timeoutSpec.label}
|
||||
value={form.timeout}
|
||||
onChange={(value) => setForm((f) => ({ ...f, timeout: value }))}
|
||||
error={timeoutError ?? undefined}
|
||||
@@ -374,12 +371,14 @@ export function EditAutomationModal({
|
||||
step={1}
|
||||
placeholder={String(AUTOMATION_TIMEOUT_DEFAULT_SECONDS)}
|
||||
/>
|
||||
<span
|
||||
data-testid="edit-automation-timeout-hint"
|
||||
className="text-xs text-muted"
|
||||
>
|
||||
{timeoutSpec.help ?? t(I18nKey.AUTOMATIONS$TIMEOUT_HINT)}
|
||||
</span>
|
||||
{timeoutSpec.help !== null && (
|
||||
<span
|
||||
data-testid="edit-automation-timeout-hint"
|
||||
className="text-xs text-muted"
|
||||
>
|
||||
{timeoutSpec.help}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -429,7 +428,7 @@ export function EditAutomationModal({
|
||||
<SettingsDropdownInput
|
||||
testId="edit-automation-frequency"
|
||||
name="frequency"
|
||||
label={scheduleSpec.label ?? t(I18nKey.AUTOMATIONS$FREQUENCY)}
|
||||
label={scheduleSpec.label}
|
||||
items={frequencyItems}
|
||||
selectedKey={form.frequency}
|
||||
isDisabled={form.isCustomSchedule}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
automationListPath,
|
||||
getInterfaceCopy,
|
||||
hasAutomationInterface,
|
||||
} from "#/manifests/automation-interface";
|
||||
|
||||
const ICON_SIZE = 18;
|
||||
@@ -53,10 +54,11 @@ export type CommandMenuItemId =
|
||||
export interface CommandMenuItemDefinition {
|
||||
id: CommandMenuItemId;
|
||||
group: CommandMenuGroupId;
|
||||
titleKey: I18nKey;
|
||||
descriptionKey: I18nKey;
|
||||
keywordsKey: I18nKey;
|
||||
/** Literal copy overriding the i18n key, when a manifest owns it. */
|
||||
/** Absent on an item whose copy a manifest owns, which sets the literal. */
|
||||
titleKey?: I18nKey;
|
||||
descriptionKey?: I18nKey;
|
||||
keywordsKey?: I18nKey;
|
||||
/** Literal copy, for an item a manifest owns. */
|
||||
title?: string;
|
||||
description?: string;
|
||||
keywords?: string;
|
||||
@@ -65,6 +67,19 @@ export interface CommandMenuItemDefinition {
|
||||
perform?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An item's copy: its literal when a manifest owns the item, its translation
|
||||
* otherwise.
|
||||
*/
|
||||
export function commandMenuItemCopy(
|
||||
literal: string | undefined,
|
||||
key: I18nKey | undefined,
|
||||
translate: (key: I18nKey) => string,
|
||||
): string {
|
||||
if (literal !== undefined) return literal;
|
||||
return key === undefined ? "" : translate(key);
|
||||
}
|
||||
|
||||
export const COMMAND_MENU_GROUP_LABELS: Record<CommandMenuGroupId, I18nKey> = {
|
||||
navigation: I18nKey.COMMAND_MENU$GROUP_NAVIGATION,
|
||||
settings: I18nKey.COMMAND_MENU$GROUP_SETTINGS,
|
||||
@@ -100,18 +115,21 @@ export const createCommandMenuItems = ({
|
||||
icon: <Sparkles size={ICON_SIZE} />,
|
||||
to: COMMAND_MENU_ROUTE.customize,
|
||||
},
|
||||
{
|
||||
id: "automations",
|
||||
group: "navigation",
|
||||
titleKey: I18nKey.COMMAND_MENU$AUTOMATIONS_TITLE,
|
||||
descriptionKey: I18nKey.COMMAND_MENU$AUTOMATIONS_DESCRIPTION,
|
||||
keywordsKey: I18nKey.COMMAND_MENU$AUTOMATIONS_KEYWORDS,
|
||||
title: getInterfaceCopy().commandMenuTitle ?? undefined,
|
||||
description: getInterfaceCopy().commandMenuDescription ?? undefined,
|
||||
keywords: getInterfaceCopy().commandMenuKeywords ?? undefined,
|
||||
icon: <Zap size={ICON_SIZE} />,
|
||||
to: COMMAND_MENU_ROUTE.automations,
|
||||
},
|
||||
// The automation interface owns this entry's copy, so an absent manifest
|
||||
// leaves the command menu without it rather than with host copy.
|
||||
...(hasAutomationInterface()
|
||||
? [
|
||||
{
|
||||
id: "automations" as const,
|
||||
group: "navigation" as const,
|
||||
title: getInterfaceCopy().commandMenuTitle,
|
||||
description: getInterfaceCopy().commandMenuDescription,
|
||||
keywords: getInterfaceCopy().commandMenuKeywords,
|
||||
icon: <Zap size={ICON_SIZE} />,
|
||||
to: COMMAND_MENU_ROUTE.automations,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "mcp",
|
||||
group: "navigation",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
COMMAND_MENU_GROUP_LABELS,
|
||||
COMMAND_MENU_GROUP_ORDER,
|
||||
type CommandMenuItemDefinition,
|
||||
commandMenuItemCopy,
|
||||
createCommandMenuItems,
|
||||
} from "./command-menu-items";
|
||||
|
||||
@@ -46,9 +47,9 @@ function matchesQuery({
|
||||
}
|
||||
|
||||
const searchableText = [
|
||||
item.title ?? translate(item.titleKey),
|
||||
item.description ?? translate(item.descriptionKey),
|
||||
item.keywords ?? translate(item.keywordsKey),
|
||||
commandMenuItemCopy(item.title, item.titleKey, translate),
|
||||
commandMenuItemCopy(item.description, item.descriptionKey, translate),
|
||||
commandMenuItemCopy(item.keywords, item.keywordsKey, translate),
|
||||
]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase();
|
||||
@@ -311,10 +312,18 @@ export function CommandMenu() {
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-current">
|
||||
{item.title ?? t(item.titleKey)}
|
||||
{commandMenuItemCopy(
|
||||
item.title,
|
||||
item.titleKey,
|
||||
t,
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-[var(--oh-text-dim)]">
|
||||
{item.description ?? t(item.descriptionKey)}
|
||||
{commandMenuItemCopy(
|
||||
item.description,
|
||||
item.descriptionKey,
|
||||
t,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden shrink-0 rounded-md border border-[var(--oh-border)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--oh-text-dim)] sm:inline-flex">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { NavigationLink } from "#/components/shared/navigation-link";
|
||||
import {
|
||||
automationListPath,
|
||||
getInterfaceCopy,
|
||||
hasAutomationInterface,
|
||||
} from "#/manifests/automation-interface";
|
||||
import { SidebarCollapsedIconSlot } from "./sidebar-collapsed-icon-slot";
|
||||
import { SidebarNavLink } from "./sidebar-nav-link";
|
||||
@@ -203,15 +204,17 @@ export function SidebarRailBody({
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<SidebarNavLink
|
||||
to={automationListPath()}
|
||||
label={
|
||||
getInterfaceCopy().sidebarLabel ?? t(I18nKey.SIDEBAR$AUTOMATIONS)
|
||||
}
|
||||
testId="sidebar-automations-link"
|
||||
collapsed={collapsed}
|
||||
icon={<AutomationsIcon width={ICON_SIZE} height={ICON_SIZE} />}
|
||||
/>
|
||||
{/* The interface manifest owns this entry's label, so an absent
|
||||
manifest leaves the rail without it rather than with host copy. */}
|
||||
{hasAutomationInterface() && (
|
||||
<SidebarNavLink
|
||||
to={automationListPath()}
|
||||
label={getInterfaceCopy().sidebarLabel}
|
||||
testId="sidebar-automations-link"
|
||||
collapsed={collapsed}
|
||||
icon={<AutomationsIcon width={ICON_SIZE} height={ICON_SIZE} />}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<SidebarConversationList collapsed={collapsed} />
|
||||
|
||||
@@ -12341,57 +12341,6 @@
|
||||
"uk": "Приватний",
|
||||
"ca": "Privat"
|
||||
},
|
||||
"SIDEBAR$AUTOMATIONS": {
|
||||
"en": "Automate",
|
||||
"zh-CN": "自动化",
|
||||
"zh-TW": "自動化",
|
||||
"de": "Automatisieren",
|
||||
"ko-KR": "자동화",
|
||||
"no": "Automatiser",
|
||||
"it": "Automatizza",
|
||||
"pt": "Automatizar",
|
||||
"es": "Automatizar",
|
||||
"ar": "أتمتة",
|
||||
"fr": "Automatiser",
|
||||
"tr": "Otomatikleştir",
|
||||
"ja": "自動化",
|
||||
"uk": "Автоматизація",
|
||||
"ca": "Automatitza"
|
||||
},
|
||||
"AUTOMATIONS$TITLE": {
|
||||
"en": "Automations",
|
||||
"ja": "オートメーション",
|
||||
"zh-CN": "自动化",
|
||||
"zh-TW": "自動化",
|
||||
"ko-KR": "자동화",
|
||||
"no": "Automatiseringer",
|
||||
"it": "Automazioni",
|
||||
"pt": "Automações",
|
||||
"es": "Automatizaciones",
|
||||
"ar": "الأتمتة",
|
||||
"fr": "Automatisations",
|
||||
"tr": "Otomasyonlar",
|
||||
"de": "Automatisierungen",
|
||||
"uk": "Автоматизації",
|
||||
"ca": "Automatitzacions"
|
||||
},
|
||||
"AUTOMATIONS$SUBTITLE": {
|
||||
"en": "View active and inactive automations, search by metadata, and inspect read-only details.",
|
||||
"ja": "アクティブおよび非アクティブなオートメーションを表示し、メタデータで検索し、読み取り専用の詳細を確認します。",
|
||||
"zh-CN": "查看活动和非活动的自动化,按元数据搜索,并检查只读详情。",
|
||||
"zh-TW": "檢視作用中與非作用中的自動化,依中繼資料搜尋,並檢查唯讀詳情。",
|
||||
"ko-KR": "활성 및 비활성 자동화를 보고, 메타데이터로 검색하고, 읽기 전용 세부 정보를 확인하세요.",
|
||||
"no": "Vis aktive og inaktive automatiseringer, søk etter metadata og inspiser skrivebeskyttede detaljer.",
|
||||
"it": "Visualizza automazioni attive e inattive, cerca per metadati e ispeziona i dettagli in sola lettura.",
|
||||
"pt": "Visualize automações ativas e inativas, pesquise por metadados e inspecione detalhes somente leitura.",
|
||||
"es": "Vea las automatizaciones activas e inactivas, busque por metadatos e inspeccione los detalles de solo lectura.",
|
||||
"ar": "اعرض الأتمتة النشطة وغير النشطة، وابحث حسب البيانات الوصفية، وافحص التفاصيل للقراءة فقط.",
|
||||
"fr": "Affichez les automatisations actives et inactives, recherchez par métadonnées et inspectez les détails en lecture seule.",
|
||||
"tr": "Aktif ve aktif olmayan otomasyonları görüntüleyin, meta verilere göre arama yapın ve salt okunur ayrıntıları inceleyin.",
|
||||
"de": "Aktive und inaktive Automatisierungen anzeigen, nach Metadaten suchen und schreibgeschützte Details überprüfen.",
|
||||
"uk": "Переглядайте активні та неактивні автоматизації, шукайте за метаданими та переглядайте деталі лише для читання.",
|
||||
"ca": "Visualitzeu les automatitzacions actives i inactives, cerqueu per metadades i inspeccioneu els detalls de només lectura."
|
||||
},
|
||||
"AUTOMATIONS$ADD_AUTOMATION": {
|
||||
"en": "Add Automation",
|
||||
"ja": "オートメーションを追加",
|
||||
@@ -13072,23 +13021,6 @@
|
||||
"uk": "Редагувати",
|
||||
"ca": "Edita"
|
||||
},
|
||||
"AUTOMATIONS$EDIT_TITLE": {
|
||||
"en": "Edit automation",
|
||||
"ja": "オートメーションを編集",
|
||||
"zh-CN": "编辑自动化",
|
||||
"zh-TW": "編輯自動化",
|
||||
"ko-KR": "자동화 편집",
|
||||
"no": "Rediger automatisering",
|
||||
"it": "Modifica automazione",
|
||||
"pt": "Editar automação",
|
||||
"es": "Editar automatización",
|
||||
"ar": "تعديل الأتمتة",
|
||||
"fr": "Modifier l'automatisation",
|
||||
"tr": "Otomasyonu düzenle",
|
||||
"de": "Automatisierung bearbeiten",
|
||||
"uk": "Редагувати автоматизацію",
|
||||
"ca": "Edita l'automatització"
|
||||
},
|
||||
"AUTOMATIONS$SAVE": {
|
||||
"en": "Save",
|
||||
"ja": "保存",
|
||||
@@ -13157,23 +13089,6 @@
|
||||
"uk": "Запит",
|
||||
"ca": "Indicació"
|
||||
},
|
||||
"AUTOMATIONS$FREQUENCY": {
|
||||
"en": "Frequency",
|
||||
"ja": "頻度",
|
||||
"zh-CN": "频率",
|
||||
"zh-TW": "頻率",
|
||||
"ko-KR": "빈도",
|
||||
"no": "Frekvens",
|
||||
"it": "Frequenza",
|
||||
"pt": "Frequência",
|
||||
"es": "Frecuencia",
|
||||
"ar": "التكرار",
|
||||
"fr": "Fréquence",
|
||||
"tr": "Sıklık",
|
||||
"de": "Häufigkeit",
|
||||
"uk": "Частота",
|
||||
"ca": "Freqüència"
|
||||
},
|
||||
"AUTOMATIONS$FREQUENCY_DAILY": {
|
||||
"en": "Daily",
|
||||
"ja": "毎日",
|
||||
@@ -13412,57 +13327,6 @@
|
||||
"uk": "Субота",
|
||||
"ca": "Dissabte"
|
||||
},
|
||||
"AUTOMATIONS$EDIT_PROMPT_HINT": {
|
||||
"en": "Edits apply to future runs only.",
|
||||
"ja": "編集内容は今後の実行にのみ適用されます。",
|
||||
"zh-CN": "编辑仅适用于后续运行。",
|
||||
"zh-TW": "編輯僅適用於後續執行。",
|
||||
"ko-KR": "편집 내용은 이후 실행에만 적용됩니다.",
|
||||
"no": "Endringer gjelder bare for fremtidige kjøringer.",
|
||||
"it": "Le modifiche si applicano solo alle esecuzioni future.",
|
||||
"pt": "As edições se aplicam apenas a execuções futuras.",
|
||||
"es": "Las ediciones se aplican solo a ejecuciones futuras.",
|
||||
"ar": "تنطبق التعديلات على عمليات التشغيل المستقبلية فقط.",
|
||||
"fr": "Les modifications s'appliquent uniquement aux exécutions futures.",
|
||||
"tr": "Değişiklikler yalnızca gelecek çalıştırmalara uygulanır.",
|
||||
"de": "Änderungen gelten nur für zukünftige Ausführungen.",
|
||||
"uk": "Зміни застосовуються лише до майбутніх запусків.",
|
||||
"ca": "Els canvis només s'apliquen a execucions futures."
|
||||
},
|
||||
"AUTOMATIONS$TIMEOUT": {
|
||||
"en": "Timeout (seconds)",
|
||||
"ja": "タイムアウト(秒)",
|
||||
"zh-CN": "超时(秒)",
|
||||
"zh-TW": "超時(秒)",
|
||||
"ko-KR": "타임아웃(초)",
|
||||
"no": "Timeout (sekunder)",
|
||||
"it": "Timeout (secondi)",
|
||||
"pt": "Timeout (segundos)",
|
||||
"es": "Timeout (segundos)",
|
||||
"ar": "المهلة الزمنية (ثوانٍ)",
|
||||
"fr": "Timeout (secondes)",
|
||||
"tr": "Zaman aşımı (saniye)",
|
||||
"de": "Timeout (Sekunden)",
|
||||
"uk": "Таймаут (секунди)",
|
||||
"ca": "Temps d'espera (segons)"
|
||||
},
|
||||
"AUTOMATIONS$TIMEOUT_HINT": {
|
||||
"en": "Maximum time a single run may take. Leave empty for the default of 600 seconds (10 minutes); maximum 1800 seconds (30 minutes).",
|
||||
"ja": "1回の実行にかかる最大時間。空欄の場合はデフォルトの600秒(10分)が使用されます。最大1800秒(30分)。",
|
||||
"zh-CN": "单次运行的最长时间。留空则使用默认值600秒(10分钟);最大1800秒(30分钟)。",
|
||||
"zh-TW": "單次執行的最長時間。留空則使用預設值600秒(10分鐘);最大1800秒(30分鐘)。",
|
||||
"ko-KR": "단일 실행에 걸리는 최대 시간입니다. 비워 두면 기본값 600초(10분)가 사용됩니다. 최대 1800초(30분).",
|
||||
"no": "Maksimal tid en enkelt kjøring kan ta. La stå tom for standardverdien på 600 sekunder (10 minutter); maksimalt 1800 sekunder (30 minutter).",
|
||||
"it": "Tempo massimo che una singola esecuzione può richiedere. Lascia vuoto per il valore predefinito di 600 secondi (10 minuti); massimo 1800 secondi (30 minuti).",
|
||||
"pt": "Tempo máximo que uma única execução pode levar. Deixe em branco para o padrão de 600 segundos (10 minutos); máximo de 1800 segundos (30 minutos).",
|
||||
"es": "Tiempo máximo que puede tardar una sola ejecución. Déjalo vacío para el valor predeterminado de 600 segundos (10 minutos); máximo 1800 segundos (30 minutos).",
|
||||
"ar": "الحد الأقصى للوقت الذي قد تستغرقه عملية تشغيل واحدة. اتركه فارغًا لاستخدام القيمة الافتراضية 600 ثانية (10 دقائق)؛ الحد الأقصى 1800 ثانية (30 دقيقة).",
|
||||
"fr": "Durée maximale d'une seule exécution. Laissez vide pour la valeur par défaut de 600 secondes (10 minutes) ; maximum 1800 secondes (30 minutes).",
|
||||
"tr": "Tek bir çalıştırmanın alabileceği maksimum süre. Varsayılan 600 saniye (10 dakika) için boş bırakın; en fazla 1800 saniye (30 dakika).",
|
||||
"de": "Maximale Dauer eines einzelnen Laufs. Leer lassen für den Standardwert von 600 Sekunden (10 Minuten); maximal 1800 Sekunden (30 Minuten).",
|
||||
"uk": "Максимальний час одного запуску. Залиште порожнім для значення за замовчуванням 600 секунд (10 хвилин); максимум 1800 секунд (30 хвилин).",
|
||||
"ca": "Temps màxim que pot trigar una sola execució. Deixa-ho buit per al valor predeterminat de 600 segons (10 minuts); màxim 1800 segons (30 minuts)."
|
||||
},
|
||||
"AUTOMATIONS$ERROR_TIMEOUT_INVALID_NUMBER": {
|
||||
"en": "Timeout must be a valid number",
|
||||
"ja": "タイムアウトは有効な数値である必要があります",
|
||||
@@ -13735,23 +13599,6 @@
|
||||
"uk": "Вартість",
|
||||
"ca": "Cost"
|
||||
},
|
||||
"AUTOMATIONS$DETAIL$BACK_TO_LIST": {
|
||||
"en": "Back to Automations",
|
||||
"ja": "オートメーション一覧に戻る",
|
||||
"zh-CN": "返回自动化",
|
||||
"zh-TW": "返回自動化",
|
||||
"ko-KR": "자동화로 돌아가기",
|
||||
"no": "Tilbake til automatiseringer",
|
||||
"it": "Torna alle automazioni",
|
||||
"pt": "Voltar para automações",
|
||||
"es": "Volver a las automatizaciones",
|
||||
"ar": "العودة إلى الأتمتة",
|
||||
"fr": "Retour aux automatisations",
|
||||
"tr": "Otomasyonlara dön",
|
||||
"de": "Zurück zu Automatisierungen",
|
||||
"uk": "Назад до автоматизацій",
|
||||
"ca": "Torna a les automatitzacions"
|
||||
},
|
||||
"AUTOMATIONS$DETAIL$CONFIGURATION": {
|
||||
"en": "Configuration",
|
||||
"ja": "設定",
|
||||
@@ -34322,57 +34169,6 @@
|
||||
"zh-CN": "skills extensions plugins customize marketplace",
|
||||
"zh-TW": "skills extensions plugins customize marketplace"
|
||||
},
|
||||
"COMMAND_MENU$AUTOMATIONS_TITLE": {
|
||||
"ar": "Automations",
|
||||
"ca": "Automations",
|
||||
"de": "Automations",
|
||||
"en": "Automations",
|
||||
"es": "Automations",
|
||||
"fr": "Automations",
|
||||
"it": "Automations",
|
||||
"ja": "自動化",
|
||||
"ko-KR": "Automations",
|
||||
"no": "Automations",
|
||||
"pt": "Automations",
|
||||
"tr": "Automations",
|
||||
"uk": "Automations",
|
||||
"zh-CN": "Automations",
|
||||
"zh-TW": "Automations"
|
||||
},
|
||||
"COMMAND_MENU$AUTOMATIONS_DESCRIPTION": {
|
||||
"ar": "Review scheduled and webhook automations.",
|
||||
"ca": "Review scheduled and webhook automations.",
|
||||
"de": "Review scheduled and webhook automations.",
|
||||
"en": "Review scheduled and webhook automations.",
|
||||
"es": "Review scheduled and webhook automations.",
|
||||
"fr": "Review scheduled and webhook automations.",
|
||||
"it": "Review scheduled and webhook automations.",
|
||||
"ja": "スケジュール済みおよび Webhook 自動化を確認します。",
|
||||
"ko-KR": "Review scheduled and webhook automations.",
|
||||
"no": "Review scheduled and webhook automations.",
|
||||
"pt": "Review scheduled and webhook automations.",
|
||||
"tr": "Review scheduled and webhook automations.",
|
||||
"uk": "Review scheduled and webhook automations.",
|
||||
"zh-CN": "Review scheduled and webhook automations.",
|
||||
"zh-TW": "Review scheduled and webhook automations."
|
||||
},
|
||||
"COMMAND_MENU$AUTOMATIONS_KEYWORDS": {
|
||||
"ar": "automate cron schedule webhook jobs",
|
||||
"ca": "automate cron schedule webhook jobs",
|
||||
"de": "automate cron schedule webhook jobs",
|
||||
"en": "automate cron schedule webhook jobs",
|
||||
"es": "automate cron schedule webhook jobs",
|
||||
"fr": "automate cron schedule webhook jobs",
|
||||
"it": "automate cron schedule webhook jobs",
|
||||
"ja": "自動化 cron スケジュール webhook ジョブ",
|
||||
"ko-KR": "automate cron schedule webhook jobs",
|
||||
"no": "automate cron schedule webhook jobs",
|
||||
"pt": "automate cron schedule webhook jobs",
|
||||
"tr": "automate cron schedule webhook jobs",
|
||||
"uk": "automate cron schedule webhook jobs",
|
||||
"zh-CN": "automate cron schedule webhook jobs",
|
||||
"zh-TW": "automate cron schedule webhook jobs"
|
||||
},
|
||||
"COMMAND_MENU$MCP_TITLE": {
|
||||
"ar": "MCP servers",
|
||||
"ca": "MCP servers",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
/**
|
||||
* The automation interface seam.
|
||||
*
|
||||
* Every automation-specific datum the host's surfaces render — routes,
|
||||
* navigation and page copy, the settable attributes, the import/export
|
||||
* envelope, endpoint paths, the featured and responder id lists — is served
|
||||
* from here.
|
||||
* The published interface manifest supplies it when the pinned
|
||||
* `@openhands/extensions` ships one and it passes admission; otherwise the
|
||||
* host's own defaults do, which reproduce today's behavior exactly. Copy
|
||||
* defaults are null, meaning "render the host's own translation".
|
||||
* Every automation-specific datum the host's surfaces render — navigation and
|
||||
* page copy, the settable attributes, the import/export envelope, endpoint
|
||||
* paths, the featured and responder id lists, the sub-page surface — is served
|
||||
* from the interface manifest the pinned `@openhands/extensions` publishes.
|
||||
* The host holds none of it. When the package publishes no manifest, or the
|
||||
* published one fails admission, there is nothing to serve: the nav entries do
|
||||
* not render and the routes 404. `hasAutomationInterface()` is that gate, and
|
||||
* every accessor that needs the manifest is reachable only from behind it.
|
||||
*
|
||||
* The sub-page surface (navigation, overview tiles, filters, sort, run
|
||||
* insights, templates page) is the exception: the host holds no definitions
|
||||
* of its own, so its accessors return null until an admitted manifest
|
||||
* declares it, and the sub-pages simply do not render.
|
||||
* Routes are the exception, and they are not a definition: `src/routes.ts`
|
||||
* mounts them, so the host states them here and admission checks a manifest's
|
||||
* routes against them. That check is what keeps a published deep link
|
||||
* resolving to the page it named.
|
||||
*/
|
||||
|
||||
import { AUTOMATION_CATALOG } from "@openhands/extensions/automations";
|
||||
@@ -42,115 +42,6 @@ const MOUNTED_ROUTES = {
|
||||
templates: "/automations/templates",
|
||||
} satisfies InterfaceRoutes;
|
||||
|
||||
const DEFAULT_ENDPOINTS: InterfaceManifest["endpoints"] = {
|
||||
list: "/v1",
|
||||
detail: "/v1/{id}",
|
||||
dispatch: "/v1/{id}/dispatch",
|
||||
runs: "/v1/{id}/runs",
|
||||
tarball: "/v1/{id}/tarball",
|
||||
health: "/health",
|
||||
capabilities: "/v1/capabilities",
|
||||
validate: "/v1/validate",
|
||||
createPrompt: "/v1/preset/prompt",
|
||||
createPlugin: "/v1/preset/plugin",
|
||||
};
|
||||
|
||||
const DEFAULT_IMPORT_EXPORT: InterfaceImportExport = {
|
||||
fileKind: "automation",
|
||||
fileVersion: 1,
|
||||
filenameSuffix: ".automation.json",
|
||||
importDefaults: {
|
||||
repoProvider: "github",
|
||||
placeholderEventSource: "agent-canvas-import",
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_DOCS_URL =
|
||||
"https://docs.openhands.dev/openhands/usage/automations/overview";
|
||||
|
||||
/**
|
||||
* Proven automations featured above the Beta group. NOT derived from
|
||||
* popularityRank (slack-standup-digest@94 outranks slack-channel-monitor@92
|
||||
* yet is Beta).
|
||||
*/
|
||||
const DEFAULT_FEATURED_AUTOMATION_IDS: readonly string[] = [
|
||||
"github-pr-reviewer",
|
||||
"github-repo-monitor",
|
||||
"slack-channel-monitor",
|
||||
];
|
||||
|
||||
/**
|
||||
* Integrations whose automations are treated as event responders that poll
|
||||
* continuously, and so get the deployment-choice dialog.
|
||||
*/
|
||||
const DEFAULT_RESPONDER_INTEGRATION_IDS: readonly string[] = [
|
||||
"github",
|
||||
"slack",
|
||||
];
|
||||
|
||||
export interface AttributeSpec {
|
||||
present: boolean;
|
||||
/** Null means "render the host's own translation". */
|
||||
label: string | null;
|
||||
help: string | null;
|
||||
required: boolean;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
}
|
||||
|
||||
const ABSENT_ATTRIBUTE: AttributeSpec = {
|
||||
present: false,
|
||||
label: null,
|
||||
help: null,
|
||||
required: false,
|
||||
min: null,
|
||||
max: null,
|
||||
};
|
||||
|
||||
/** Today's edit dialog, restated as specs so absence of a manifest changes nothing. */
|
||||
const DEFAULT_ATTRIBUTES: Record<AutomationAttributeName, AttributeSpec> = {
|
||||
name: {
|
||||
present: true,
|
||||
label: null,
|
||||
help: null,
|
||||
required: true,
|
||||
min: null,
|
||||
max: null,
|
||||
},
|
||||
prompt: {
|
||||
present: true,
|
||||
label: null,
|
||||
help: null,
|
||||
required: false,
|
||||
min: null,
|
||||
max: null,
|
||||
},
|
||||
model: {
|
||||
present: true,
|
||||
label: null,
|
||||
help: null,
|
||||
required: false,
|
||||
min: null,
|
||||
max: null,
|
||||
},
|
||||
timeout: {
|
||||
present: true,
|
||||
label: null,
|
||||
help: null,
|
||||
required: false,
|
||||
min: 1,
|
||||
max: null,
|
||||
},
|
||||
schedule: {
|
||||
present: true,
|
||||
label: null,
|
||||
help: null,
|
||||
required: false,
|
||||
min: null,
|
||||
max: null,
|
||||
},
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -172,7 +63,7 @@ function admitInterfaceManifest(candidate: unknown): InterfaceManifest | null {
|
||||
});
|
||||
if (!result.valid) {
|
||||
// Mirrors the setup registry: a rejected manifest is skipped loudly, and
|
||||
// the host's defaults stand.
|
||||
// the surfaces it would have described stay unrendered.
|
||||
console.warn(
|
||||
"Rejected the automation interface manifest:",
|
||||
result.errors.join("; "),
|
||||
@@ -184,28 +75,50 @@ function admitInterfaceManifest(candidate: unknown): InterfaceManifest | null {
|
||||
|
||||
const ADMITTED = admitInterfaceManifest(AUTOMATION_INTERFACE_CANDIDATE);
|
||||
|
||||
/**
|
||||
* Whether this deployment has an automation interface at all. The nav entries
|
||||
* and the route loaders ask before rendering; nothing else has to.
|
||||
*/
|
||||
export function hasAutomationInterface(): boolean {
|
||||
return ADMITTED !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admitted manifest, for the accessors that cannot answer without one.
|
||||
* Reaching one of those without a manifest means a surface rendered past its
|
||||
* gate — a wiring mistake, not a state to render.
|
||||
*/
|
||||
function requireInterface(): InterfaceManifest {
|
||||
if (!ADMITTED) {
|
||||
throw new Error(
|
||||
"No automation interface manifest is admitted, so this surface should not have rendered.",
|
||||
);
|
||||
}
|
||||
return ADMITTED;
|
||||
}
|
||||
|
||||
function substituteRouteParam(pattern: string, id: string): string {
|
||||
return pattern.replace(":automationId", encodeURIComponent(id));
|
||||
}
|
||||
|
||||
export function automationListPath(): string {
|
||||
return (ADMITTED?.routes ?? MOUNTED_ROUTES).list;
|
||||
return MOUNTED_ROUTES.list;
|
||||
}
|
||||
|
||||
export function automationSetupPath(id: string): string {
|
||||
return substituteRouteParam((ADMITTED?.routes ?? MOUNTED_ROUTES).setup, id);
|
||||
return substituteRouteParam(MOUNTED_ROUTES.setup, id);
|
||||
}
|
||||
|
||||
export function automationDetailPath(id: string): string {
|
||||
return substituteRouteParam((ADMITTED?.routes ?? MOUNTED_ROUTES).detail, id);
|
||||
return substituteRouteParam(MOUNTED_ROUTES.detail, id);
|
||||
}
|
||||
|
||||
export function automationTemplatesPath(): string {
|
||||
return ADMITTED?.routes.templates ?? MOUNTED_ROUTES.templates;
|
||||
return MOUNTED_ROUTES.templates;
|
||||
}
|
||||
|
||||
export function getAutomationEndpoint(name: InterfaceEndpointName): string {
|
||||
return (ADMITTED?.endpoints ?? DEFAULT_ENDPOINTS)[name];
|
||||
return requireInterface().endpoints[name];
|
||||
}
|
||||
|
||||
/** An id-parameterized endpoint with `{id}` substituted, encoded. */
|
||||
@@ -217,45 +130,52 @@ export function getAutomationIdEndpoint(
|
||||
}
|
||||
|
||||
export interface InterfaceCopy {
|
||||
sidebarLabel: string | null;
|
||||
commandMenuTitle: string | null;
|
||||
commandMenuDescription: string | null;
|
||||
commandMenuKeywords: string | null;
|
||||
listTitle: string | null;
|
||||
listSubtitle: string | null;
|
||||
detailBackLabel: string | null;
|
||||
editTitle: string | null;
|
||||
sidebarLabel: string;
|
||||
commandMenuTitle: string;
|
||||
commandMenuDescription: string;
|
||||
commandMenuKeywords: string;
|
||||
listTitle: string;
|
||||
listSubtitle: string;
|
||||
detailBackLabel: string;
|
||||
editTitle: string;
|
||||
}
|
||||
|
||||
const HOST_COPY: InterfaceCopy = {
|
||||
sidebarLabel: null,
|
||||
commandMenuTitle: null,
|
||||
commandMenuDescription: null,
|
||||
commandMenuKeywords: null,
|
||||
listTitle: null,
|
||||
listSubtitle: null,
|
||||
detailBackLabel: null,
|
||||
editTitle: null,
|
||||
};
|
||||
|
||||
export function getInterfaceCopy(): InterfaceCopy {
|
||||
if (!ADMITTED) return HOST_COPY;
|
||||
const manifest = requireInterface();
|
||||
return {
|
||||
sidebarLabel: ADMITTED.navigation.sidebar.label,
|
||||
commandMenuTitle: ADMITTED.navigation.commandMenu.title,
|
||||
commandMenuDescription: ADMITTED.navigation.commandMenu.description,
|
||||
commandMenuKeywords: ADMITTED.navigation.commandMenu.keywords,
|
||||
listTitle: ADMITTED.pages.list.title,
|
||||
listSubtitle: ADMITTED.pages.list.subtitle,
|
||||
detailBackLabel: ADMITTED.pages.detail.backLabel,
|
||||
editTitle: ADMITTED.pages.edit.title,
|
||||
sidebarLabel: manifest.navigation.sidebar.label,
|
||||
commandMenuTitle: manifest.navigation.commandMenu.title,
|
||||
commandMenuDescription: manifest.navigation.commandMenu.description,
|
||||
commandMenuKeywords: manifest.navigation.commandMenu.keywords,
|
||||
listTitle: manifest.pages.list.title,
|
||||
listSubtitle: manifest.pages.list.subtitle,
|
||||
detailBackLabel: manifest.pages.detail.backLabel,
|
||||
editTitle: manifest.pages.edit.title,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAttributeSpec(name: AutomationAttributeName): AttributeSpec {
|
||||
if (!ADMITTED) return DEFAULT_ATTRIBUTES[name];
|
||||
export interface AttributeSpec {
|
||||
present: boolean;
|
||||
label: string;
|
||||
/** Null when the manifest states no help text for the attribute. */
|
||||
help: string | null;
|
||||
required: boolean;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
}
|
||||
|
||||
const attribute = ADMITTED.attributes[name];
|
||||
/** An attribute the manifest does not declare is not offered at all. */
|
||||
const ABSENT_ATTRIBUTE: AttributeSpec = {
|
||||
present: false,
|
||||
label: "",
|
||||
help: null,
|
||||
required: false,
|
||||
min: null,
|
||||
max: null,
|
||||
};
|
||||
|
||||
export function getAttributeSpec(name: AutomationAttributeName): AttributeSpec {
|
||||
const attribute = requireInterface().attributes[name];
|
||||
if (!attribute) return ABSENT_ATTRIBUTE;
|
||||
return {
|
||||
present: true,
|
||||
@@ -268,24 +188,24 @@ export function getAttributeSpec(name: AutomationAttributeName): AttributeSpec {
|
||||
}
|
||||
|
||||
export function getImportExportSpec(): InterfaceImportExport {
|
||||
return ADMITTED?.importExport ?? DEFAULT_IMPORT_EXPORT;
|
||||
return requireInterface().importExport;
|
||||
}
|
||||
|
||||
export function getAutomationsDocsUrl(): string {
|
||||
return ADMITTED?.docsUrl ?? DEFAULT_DOCS_URL;
|
||||
return requireInterface().docsUrl;
|
||||
}
|
||||
|
||||
export function getFeaturedAutomationIds(): readonly string[] {
|
||||
return ADMITTED?.featuredAutomationIds ?? DEFAULT_FEATURED_AUTOMATION_IDS;
|
||||
return requireInterface().featuredAutomationIds;
|
||||
}
|
||||
|
||||
export function getResponderIntegrationIds(): readonly string[] {
|
||||
return ADMITTED?.responderIntegrationIds ?? DEFAULT_RESPONDER_INTEGRATION_IDS;
|
||||
return requireInterface().responderIntegrationIds;
|
||||
}
|
||||
|
||||
export interface SubPageNavSpec {
|
||||
page: InterfaceSubPageId;
|
||||
/** The page's route, resolved through the manifest. */
|
||||
/** The page's route, resolved through the host's route table. */
|
||||
to: string;
|
||||
label: string;
|
||||
icon: InterfaceIconSlug;
|
||||
@@ -293,7 +213,7 @@ export interface SubPageNavSpec {
|
||||
|
||||
/**
|
||||
* The sub-page navigation, or null when the manifest does not declare the
|
||||
* sub-page surface. No manifest, no sub-pages — there is no host default.
|
||||
* sub-page surface.
|
||||
*/
|
||||
export function getSubPagesSpec(): SubPageNavSpec[] | null {
|
||||
const subPages = ADMITTED?.navigation.subPages;
|
||||
|
||||
@@ -25,8 +25,14 @@ import type {
|
||||
SetupTriggerKind,
|
||||
} from "./types";
|
||||
|
||||
/** The creation endpoint a derived draft would be posted to. */
|
||||
export const AUTOMATION_CREATE_ENDPOINT = getAutomationEndpoint("createPrompt");
|
||||
/**
|
||||
* The creation endpoint a derived draft would be posted to. Resolved on call
|
||||
* rather than at import, because the endpoint is the interface manifest's and
|
||||
* this module loads whether or not one was admitted.
|
||||
*/
|
||||
export function automationCreateEndpoint(): string {
|
||||
return getAutomationEndpoint("createPrompt");
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger properties a form field may fill, per trigger kind. A field under a
|
||||
@@ -142,7 +148,7 @@ export function buildPreflightBody(
|
||||
|
||||
return {
|
||||
automationId: entry.id,
|
||||
endpoint: AUTOMATION_CREATE_ENDPOINT,
|
||||
endpoint: automationCreateEndpoint(),
|
||||
draft,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
import { useAutomationHealth } from "#/hooks/query/use-automation-health";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { useNavigation } from "#/context/navigation-context";
|
||||
import { automationListPath } from "#/manifests/automation-interface";
|
||||
import {
|
||||
automationListPath,
|
||||
hasAutomationInterface,
|
||||
} from "#/manifests/automation-interface";
|
||||
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";
|
||||
@@ -39,6 +42,18 @@ import {
|
||||
} from "#/utils/automation-export";
|
||||
import { downloadBlob } from "#/utils/utils";
|
||||
|
||||
/**
|
||||
* The page renders the interface manifest's copy, so without an admitted
|
||||
* manifest there is nothing to render: a 404, which the layout's error
|
||||
* boundary renders.
|
||||
*/
|
||||
export const clientLoader = () => {
|
||||
if (!hasAutomationInterface()) {
|
||||
throw new Response(null, { status: 404, statusText: "Not Found" });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function AutomationDetail() {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { automationId } = useParams();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useLoaderData, useLocation, useNavigate } from "react-router";
|
||||
import { Route } from "./+types/automation-setup-route";
|
||||
import { automationListPath } from "#/manifests/automation-interface";
|
||||
import {
|
||||
automationListPath,
|
||||
hasAutomationInterface,
|
||||
} from "#/manifests/automation-interface";
|
||||
import { SETUP_REGISTRY } from "#/manifests/manifest-sources";
|
||||
import { SetupDialog } from "#/components/features/manifest/manifest-setup-dialog";
|
||||
|
||||
@@ -15,7 +18,9 @@ import { SetupDialog } from "#/components/features/manifest/manifest-setup-dialo
|
||||
export const clientLoader = ({ params }: Route.ClientLoaderArgs) => {
|
||||
const entry = SETUP_REGISTRY.findById(params.automationId ?? "");
|
||||
|
||||
if (!entry) {
|
||||
// Setup submits against the interface manifest's endpoints, so without an
|
||||
// admitted manifest the page has nothing to submit to either.
|
||||
if (!entry || !hasAutomationInterface()) {
|
||||
throw new Response(null, { status: 404, statusText: "Not Found" });
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
automationDetailPath,
|
||||
getDashboardSpec,
|
||||
getInterfaceCopy,
|
||||
hasAutomationInterface,
|
||||
} from "#/manifests/automation-interface";
|
||||
import {
|
||||
applyDashboardView,
|
||||
@@ -77,6 +78,18 @@ import { cn, downloadBlob } from "#/utils/utils";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* The page renders the interface manifest's copy, so without an admitted
|
||||
* manifest there is nothing to render: a 404, which the layout's error
|
||||
* boundary renders.
|
||||
*/
|
||||
export const clientLoader = () => {
|
||||
if (!hasAutomationInterface()) {
|
||||
throw new Response(null, { status: 404, statusText: "Not Found" });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function AutomationsList() {
|
||||
const { t } = useTranslation("openhands");
|
||||
const interfaceCopy = getInterfaceCopy();
|
||||
@@ -332,11 +345,9 @@ export default function AutomationsList() {
|
||||
return renderShell(
|
||||
<div>
|
||||
<h1 className="text-xl font-medium text-content">
|
||||
{interfaceCopy.listTitle ?? t(I18nKey.AUTOMATIONS$TITLE)}
|
||||
{interfaceCopy.listTitle}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{interfaceCopy.listSubtitle ?? t(I18nKey.AUTOMATIONS$SUBTITLE)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted">{interfaceCopy.listSubtitle}</p>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<AutomationCardSkeleton key={`skeleton-${String(i)}`} />
|
||||
@@ -351,11 +362,9 @@ export default function AutomationsList() {
|
||||
return renderShell(
|
||||
<div>
|
||||
<h1 className="text-xl font-medium text-content">
|
||||
{interfaceCopy.listTitle ?? t(I18nKey.AUTOMATIONS$TITLE)}
|
||||
{interfaceCopy.listTitle}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{interfaceCopy.listSubtitle ?? t(I18nKey.AUTOMATIONS$SUBTITLE)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted">{interfaceCopy.listSubtitle}</p>
|
||||
<BackendNotConfigured onRetry={refetchHealth} />
|
||||
</div>,
|
||||
);
|
||||
@@ -367,10 +376,10 @@ export default function AutomationsList() {
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold text-content">
|
||||
{interfaceCopy.listTitle ?? t(I18nKey.AUTOMATIONS$TITLE)}
|
||||
{interfaceCopy.listTitle}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{interfaceCopy.listSubtitle ?? t(I18nKey.AUTOMATIONS$SUBTITLE)}
|
||||
{interfaceCopy.listSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
|
||||
Reference in New Issue
Block a user