refactor: serve the automation interface from the manifest alone (#16676)

This commit is contained in:
Vasco Schiavo
2026-08-18 12:18:17 +02:00
committed by GitHub
parent 342175e479
commit 96b6aab34d
27 changed files with 358 additions and 517 deletions
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();
});
});
+8 -4
View File
@@ -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} />
-204
View File
@@ -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",
+84 -164
View File
@@ -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;
+9 -3
View File
@@ -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,
};
}
+16 -1
View File
@@ -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();
+7 -2
View File
@@ -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" });
}
+19 -10
View File
@@ -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">