mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
fix(autopilots): save the first schedule added from the edit dialog (MUL-5649) (#6303)
Editing a manual-only autopilot showed the schedule panel seeded with the editor's default — 09:00 every day, next runs and all — as though that were the autopilot's schedule. It was not: the autopilot had no trigger. Saving then compared that default against itself, found no change, wrote nothing, and toasted "Autopilot updated" while the detail page went on reading "No triggers configured". The dirty check is right when a schedule exists (it stops a re-picked, unchanged cron from being rewritten) and meaningless when none does, because the panel is showing a proposal rather than stored state. So the panel now says what is true — a dashed card matching the detail page's empty state, "No schedule — this autopilot only runs when triggered manually" — and asks for the schedule explicitly. Adding one writes it on save whether or not the user touched the default; leaving it alone keeps the autopilot manual, so a title-only edit can no longer put it on a daily cron by accident. The footer's auto-run promise drops out while that empty state is up. The schedule write also targets the first `schedule` trigger rather than `triggers[0]`, which on an api-triggered autopilot was a row a cron could have been patched into. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import { useImperativeHandle, useRef, useState } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { AutopilotTrigger } from "@multica/core/types";
|
||||
import { renderWithI18n } from "../../test/i18n";
|
||||
|
||||
// Regression cover for MUL-5649: editing a manual-only autopilot (no triggers)
|
||||
// showed the schedule panel seeded with the editor's default — 09:00 every day
|
||||
// — as if that were the autopilot's schedule. Saving compared that default
|
||||
// against itself, found no change, and wrote nothing, while the toast said the
|
||||
// autopilot was updated and the detail page still read "No triggers
|
||||
// configured". The panel now states what is true (no schedule) and asks for the
|
||||
// schedule to be added explicitly; adding one writes it, default or not.
|
||||
|
||||
const mockUpdateAutopilot = vi.hoisted(() => vi.fn());
|
||||
const mockCreateTrigger = vi.hoisted(() => vi.fn());
|
||||
const mockUpdateTrigger = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-test" }));
|
||||
vi.mock("@multica/core/paths", () => ({ useCurrentWorkspace: () => ({ name: "Acme" }) }));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
agentListOptions: (wsId: string) => ({
|
||||
queryKey: ["agents", wsId],
|
||||
queryFn: async () => [
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "Scout",
|
||||
description: "Researches things",
|
||||
archived_at: null,
|
||||
runtime_id: "runtime-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
squadListOptions: (wsId: string) => ({
|
||||
queryKey: ["squads", wsId],
|
||||
queryFn: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/projects/queries", () => ({
|
||||
projectListOptions: (wsId: string) => ({
|
||||
queryKey: ["projects", wsId],
|
||||
queryFn: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/autopilots/queries", () => ({
|
||||
cronPreviewOptions: (wsId: string, expr: string, tz: string) => ({
|
||||
queryKey: ["cron-preview", wsId, expr, tz],
|
||||
queryFn: async () => ({ next_runs: ["2126-07-14T01:00:00Z"] }),
|
||||
retry: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/autopilots/mutations", () => ({
|
||||
useCreateAutopilot: () => ({ mutateAsync: vi.fn() }),
|
||||
useCreateAutopilotTrigger: () => ({ mutateAsync: mockCreateTrigger }),
|
||||
useUpdateAutopilot: () => ({ mutateAsync: mockUpdateAutopilot }),
|
||||
useUpdateAutopilotTrigger: () => ({ mutateAsync: mockUpdateTrigger }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
vi.mock("../../editor", () => ({
|
||||
TitleEditor: ({ ref, defaultValue, placeholder, onChange, onSubmit }: any) => {
|
||||
const [value, setValue] = useState(defaultValue ?? "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useImperativeHandle(ref, () => ({
|
||||
getText: () => value,
|
||||
focus: () => inputRef.current?.focus(),
|
||||
focusAtCoords: () => inputRef.current?.focus(),
|
||||
}));
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
aria-label="title"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSubmit?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
ContentEditor: ({ placeholder }: any) => <textarea aria-label="runbook" placeholder={placeholder} />,
|
||||
}));
|
||||
|
||||
vi.mock("../../common/actor-avatar", () => ({
|
||||
ActorAvatar: ({ actorId }: { actorId: string }) => <span data-testid="actor-avatar">{actorId}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("./subscriber-multi-select", () => ({
|
||||
SubscriberMultiSelect: () => <div data-testid="subscriber-multi-select" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../projects/components/project-picker", () => ({
|
||||
ProjectPicker: ({ triggerRender }: { triggerRender: React.ReactElement }) => triggerRender,
|
||||
}));
|
||||
|
||||
vi.mock("./pickers/timezone-picker", () => ({
|
||||
TimezonePicker: ({ value }: { value: string }) => <div data-testid="timezone-picker">{value}</div>,
|
||||
}));
|
||||
|
||||
import { AutopilotDialog } from "./autopilot-dialog";
|
||||
|
||||
const AUTOPILOT_ID = "ap-1";
|
||||
|
||||
function trigger(overrides: Partial<AutopilotTrigger> = {}): AutopilotTrigger {
|
||||
return {
|
||||
id: "trg-1",
|
||||
autopilot_id: AUTOPILOT_ID,
|
||||
kind: "schedule",
|
||||
enabled: true,
|
||||
cron_expression: "TZ=Asia/Shanghai 30 8 * * *",
|
||||
timezone: "Asia/Shanghai",
|
||||
next_run_at: null,
|
||||
webhook_token: null,
|
||||
label: null,
|
||||
last_fired_at: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderEditDialog(triggers: AutopilotTrigger[]) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return renderWithI18n(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AutopilotDialog
|
||||
mode="edit"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
autopilotId={AUTOPILOT_ID}
|
||||
initial={{
|
||||
title: "Issue title sweep",
|
||||
description: "",
|
||||
project_id: null,
|
||||
assignee_type: "agent",
|
||||
assignee_id: "agent-1",
|
||||
execution_mode: "run_only",
|
||||
subscriber_user_ids: [],
|
||||
}}
|
||||
triggers={triggers}
|
||||
collaborators={[]}
|
||||
canManageAccess={false}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const saveButton = () => screen.getByRole("button", { name: "Save" });
|
||||
const addScheduleButton = () => screen.getByRole("button", { name: "Add schedule" });
|
||||
|
||||
describe("AutopilotDialog schedule section on an autopilot with no schedule", () => {
|
||||
beforeEach(() => {
|
||||
mockUpdateAutopilot.mockReset().mockResolvedValue({ id: AUTOPILOT_ID });
|
||||
mockCreateTrigger.mockReset().mockResolvedValue({ id: "trg-new" });
|
||||
mockUpdateTrigger.mockReset().mockResolvedValue({ id: "trg-1" });
|
||||
});
|
||||
|
||||
it("says the autopilot has no schedule instead of showing a fabricated one", () => {
|
||||
renderEditDialog([]);
|
||||
|
||||
expect(
|
||||
screen.getByText("No schedule — this autopilot only runs when triggered manually."),
|
||||
).toBeInTheDocument();
|
||||
expect(addScheduleButton()).toBeInTheDocument();
|
||||
// The editor — and with it the next-run preview that reads as a promise —
|
||||
// stays out of sight until the user asks for a schedule, and the footer
|
||||
// stops promising automatic runs the autopilot will not make.
|
||||
expect(screen.queryByTestId("timezone-picker")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Once saved, runs automatically until paused."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores the auto-run hint once a schedule is added", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderEditDialog([]);
|
||||
|
||||
await user.click(addScheduleButton());
|
||||
|
||||
expect(
|
||||
await screen.findByText("Once saved, runs automatically until paused."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("writes the schedule on save even when the user keeps the default 09:00", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderEditDialog([]);
|
||||
|
||||
await user.click(addScheduleButton());
|
||||
expect(await screen.findByTestId("timezone-picker")).toBeInTheDocument();
|
||||
|
||||
await user.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(mockCreateTrigger).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreateTrigger.mock.calls[0]?.[0]).toMatchObject({
|
||||
autopilotId: AUTOPILOT_ID,
|
||||
kind: "schedule",
|
||||
});
|
||||
expect(mockCreateTrigger.mock.calls[0]?.[0].cron_expression).toMatch(/(^|\s)0 9 \* \* \*$/);
|
||||
expect(mockUpdateTrigger).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the autopilot manual when the user only edits other fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderEditDialog([]);
|
||||
|
||||
await user.type(screen.getByLabelText("title"), " v2");
|
||||
await user.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(mockUpdateAutopilot).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreateTrigger).not.toHaveBeenCalled();
|
||||
expect(mockUpdateTrigger).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never patches a cron into a non-schedule trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
// An api-kind trigger is not a schedule: the panel must offer to create one
|
||||
// rather than treat that row as the schedule it is about to overwrite.
|
||||
renderEditDialog([trigger({ kind: "api", cron_expression: null, timezone: null })]);
|
||||
|
||||
await user.click(addScheduleButton());
|
||||
await user.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(mockCreateTrigger).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdateTrigger).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutopilotDialog schedule section on an autopilot that has one", () => {
|
||||
beforeEach(() => {
|
||||
mockUpdateAutopilot.mockReset().mockResolvedValue({ id: AUTOPILOT_ID });
|
||||
mockCreateTrigger.mockReset().mockResolvedValue({ id: "trg-new" });
|
||||
mockUpdateTrigger.mockReset().mockResolvedValue({ id: "trg-1" });
|
||||
});
|
||||
|
||||
it("shows the stored schedule, not the empty state", () => {
|
||||
renderEditDialog([trigger()]);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Add schedule" })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("timezone-picker")).toHaveTextContent("Asia/Shanghai");
|
||||
});
|
||||
|
||||
it("does not rewrite a schedule the user never changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderEditDialog([trigger()]);
|
||||
|
||||
await user.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(mockUpdateAutopilot).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdateTrigger).not.toHaveBeenCalled();
|
||||
expect(mockCreateTrigger).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Play,
|
||||
Plus,
|
||||
Rocket,
|
||||
Users,
|
||||
Webhook,
|
||||
@@ -163,20 +164,35 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
initial.subscriber_user_ids ?? [],
|
||||
);
|
||||
|
||||
// The schedule panel speaks for the autopilot's SCHEDULE trigger, not for
|
||||
// `triggers[0]` — on a webhook- or api-triggered autopilot that row is one no
|
||||
// cron may be written into.
|
||||
const existingSchedule = isCreate
|
||||
? null
|
||||
: props.triggers.find((trig) => trig.kind === "schedule") ?? null;
|
||||
|
||||
const initialCfg: ScheduleConfig = (() => {
|
||||
if (isCreate) {
|
||||
const tpl = props.initialSchedule;
|
||||
const fallback = getDefaultScheduleConfig(browserTimezone());
|
||||
return tpl ? { ...fallback, ...tpl } : fallback;
|
||||
}
|
||||
const first = props.triggers[0];
|
||||
if (first?.cron_expression) {
|
||||
return parseCron(first.cron_expression, first.timezone ?? "UTC");
|
||||
if (existingSchedule?.cron_expression) {
|
||||
return parseCron(existingSchedule.cron_expression, existingSchedule.timezone ?? "UTC");
|
||||
}
|
||||
return getDefaultScheduleConfig(browserTimezone());
|
||||
})();
|
||||
const [schedule, setSchedule] = useState<ScheduleConfig>(initialCfg);
|
||||
|
||||
// Editing an autopilot that has no schedule: the panel has nothing to
|
||||
// reflect, so anything it showed would be a proposal dressed as the
|
||||
// autopilot's state — and `scheduleDirty` below, comparing that proposal
|
||||
// against itself, then dropped the save on the floor under a success toast
|
||||
// (MUL-5649). The schedule is asked for explicitly instead: until the user
|
||||
// adds one, the panel says the autopilot is manual, and once they do, Save
|
||||
// writes what it shows whether or not they touched the default.
|
||||
const [scheduleAdded, setScheduleAdded] = useState(false);
|
||||
|
||||
// Trigger kind selector. Only meaningful in create mode — edit mode does
|
||||
// not support converting between kinds inline (PLAN.md calls that
|
||||
// out as "delete old, create new" rather than ambiguous in-place
|
||||
@@ -207,10 +223,19 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
const firstTriggerIdRef = useRef(
|
||||
!isCreate && props.triggers[0] ? props.triggers[0].id : null,
|
||||
);
|
||||
// The row the schedule write targets, snapshotted at mount like the one
|
||||
// above. Null means there is none to patch, so the write creates one.
|
||||
const scheduleTriggerIdRef = useRef(existingSchedule?.id ?? null);
|
||||
|
||||
const triggerCount = isCreate ? 0 : props.triggers.length;
|
||||
const schedulePillDisabled = !isCreate && triggerCount >= 2;
|
||||
|
||||
// The manual-autopilot empty state, and the only path to a first schedule
|
||||
// from this dialog. Skipped when the panel is locked (2+ triggers), which
|
||||
// keeps that case rendering exactly the disabled editor it always has.
|
||||
const showScheduleEmptyState =
|
||||
!isCreate && existingSchedule === null && !scheduleAdded && !schedulePillDisabled;
|
||||
|
||||
const selectedAssignee = useMemo(() => {
|
||||
if (!assigneeId) return null;
|
||||
if (assigneeType === "squad") {
|
||||
@@ -245,12 +270,15 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
const scheduleGate = useScheduleSubmitGate(wsId);
|
||||
|
||||
// The schedule only gates submit when this save would actually write it. A
|
||||
// locked schedule (2+ triggers) or one the user never touched is not sent, so
|
||||
// a preview 400 on the stored expression — an expression the server accepted
|
||||
// once and may now reject, e.g. a timezone its tzdata dropped — must not veto
|
||||
// edits to the title, prompt or assignee.
|
||||
// locked schedule (2+ triggers) or a stored one the user never touched is not
|
||||
// sent, so a preview 400 on the stored expression — an expression the server
|
||||
// accepted once and may now reject, e.g. a timezone its tzdata dropped — must
|
||||
// not veto edits to the title, prompt or assignee. A schedule the user just
|
||||
// added has no stored counterpart to differ from: adding it IS the change.
|
||||
const scheduleWillBeWritten =
|
||||
triggerKind === "schedule" && !schedulePillDisabled && (isCreate || scheduleDirty);
|
||||
triggerKind === "schedule" &&
|
||||
!schedulePillDisabled &&
|
||||
(isCreate || (existingSchedule !== null ? scheduleDirty : scheduleAdded));
|
||||
|
||||
// The FIRST empty required field in reading order — the user fills one, the
|
||||
// next surfaces. Only these two are answered here: a rejected schedule is
|
||||
@@ -355,7 +383,7 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
// webhook — there's no cron to update there, and the schedule
|
||||
// panel isn't even rendered for webhook autopilots.
|
||||
if (scheduleWillBeWritten) {
|
||||
const snapshottedTriggerId = firstTriggerIdRef.current;
|
||||
const snapshottedTriggerId = scheduleTriggerIdRef.current;
|
||||
try {
|
||||
if (snapshottedTriggerId) {
|
||||
await updateTrigger.mutateAsync({
|
||||
@@ -615,27 +643,31 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
{triggerKind === "schedule" ? (
|
||||
<div>
|
||||
<SectionLabel>{t(($) => $.dialog.section_schedule)}</SectionLabel>
|
||||
{/* No `onValidityChange` / `clearRejection` here, unlike the
|
||||
detail page's add-trigger dialog: nothing in this footer is
|
||||
gated on the schedule's validity any more, so the gate's
|
||||
`scheduleValid` would have no reader. The editor still shows
|
||||
its own inline rejection, and `ensureAccepted` re-asks the
|
||||
server on submit and toasts what it says. */}
|
||||
<ScheduleEditor
|
||||
value={schedule}
|
||||
onChange={setSchedule}
|
||||
wsId={wsId}
|
||||
// Locked while the save is in flight: the submit path validates
|
||||
// over the network and then writes the schedule it read before
|
||||
// that round trip, so an edit made in between would be dropped
|
||||
// on the floor with a success toast over it.
|
||||
disabled={schedulePillDisabled || submitting}
|
||||
disabledReason={
|
||||
schedulePillDisabled
|
||||
? t(($) => $.dialog.schedule_disabled_reason)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{showScheduleEmptyState ? (
|
||||
<ScheduleEmptyState onAdd={() => setScheduleAdded(true)} />
|
||||
) : (
|
||||
/* No `onValidityChange` / `clearRejection` here, unlike the
|
||||
detail page's add-trigger dialog: nothing in this footer is
|
||||
gated on the schedule's validity any more, so the gate's
|
||||
`scheduleValid` would have no reader. The editor still shows
|
||||
its own inline rejection, and `ensureAccepted` re-asks the
|
||||
server on submit and toasts what it says. */
|
||||
<ScheduleEditor
|
||||
value={schedule}
|
||||
onChange={setSchedule}
|
||||
wsId={wsId}
|
||||
// Locked while the save is in flight: the submit path validates
|
||||
// over the network and then writes the schedule it read before
|
||||
// that round trip, so an edit made in between would be dropped
|
||||
// on the floor with a success toast over it.
|
||||
disabled={schedulePillDisabled || submitting}
|
||||
disabledReason={
|
||||
schedulePillDisabled
|
||||
? t(($) => $.dialog.schedule_disabled_reason)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<WebhookSection
|
||||
@@ -649,9 +681,18 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-3 border-t shrink-0 bg-background">
|
||||
{/* The hint drops out while the schedule section states the autopilot
|
||||
is manual — a footer promising automatic runs directly under that
|
||||
is the same false promise the empty state exists to retire. The
|
||||
slot itself stays, or `justify-between` would walk the buttons
|
||||
over to the left edge. */}
|
||||
<div className="flex items-center gap-1.5 text-caption text-muted-foreground min-w-0">
|
||||
<Zap className="size-3.5 text-amber-500 shrink-0" />
|
||||
<span className="truncate">{t(($) => $.dialog.auto_run_hint)}</span>
|
||||
{!showScheduleEmptyState && (
|
||||
<>
|
||||
<Zap className="size-3.5 text-amber-500 shrink-0" />
|
||||
<span className="truncate">{t(($) => $.dialog.auto_run_hint)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button size="sm" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
@@ -914,6 +955,26 @@ function SubscribersSection({
|
||||
}
|
||||
|
||||
|
||||
// The schedule section of an autopilot that has none. Mirrors the detail
|
||||
// page's trigger empty state — a dashed card that states the autopilot is
|
||||
// manual — so the two surfaces agree on what "no schedule" looks like instead
|
||||
// of one of them showing a filled-in editor and a next-run preview for a
|
||||
// schedule that does not exist.
|
||||
function ScheduleEmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
const { t } = useT("autopilots");
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-caption text-muted-foreground">
|
||||
{t(($) => $.dialog.schedule_empty)}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" className="mt-2.5" onClick={onAdd}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
{t(($) => $.dialog.schedule_add)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger kind segmented control + webhook help section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -310,6 +310,8 @@
|
||||
"event_filter_actions_placeholder": "completed, failed",
|
||||
"event_filter_hint": "Only process webhooks matching these events. Leave empty to accept all.",
|
||||
"schedule_disabled_reason": "This autopilot has multiple schedules — edit them in the detail page.",
|
||||
"schedule_empty": "No schedule — this autopilot only runs when triggered manually.",
|
||||
"schedule_add": "Add schedule",
|
||||
"error_title_required": "Enter a name for this autopilot.",
|
||||
"error_assignee_required": "Choose the agent or squad that will run this autopilot.",
|
||||
"output_modes": {
|
||||
|
||||
@@ -310,6 +310,8 @@
|
||||
"event_filter_actions_placeholder": "completed, failed",
|
||||
"event_filter_hint": "これらのイベントに一致する Webhook のみを処理します。空にするとすべて許可します。",
|
||||
"schedule_disabled_reason": "このオートパイロットには複数のスケジュールがあります。詳細ページで編集してください。",
|
||||
"schedule_empty": "スケジュールはありません。このオートパイロットは手動で実行したときだけ動きます。",
|
||||
"schedule_add": "スケジュールを追加",
|
||||
"error_title_required": "オートパイロット名を入力してください。",
|
||||
"error_assignee_required": "実行するエージェントまたはスクワッドを選択してください。",
|
||||
"output_modes": {
|
||||
|
||||
@@ -310,6 +310,8 @@
|
||||
"event_filter_actions_placeholder": "completed, failed",
|
||||
"event_filter_hint": "이 이벤트와 일치하는 Webhook만 처리합니다. 비워 두면 모두 허용합니다.",
|
||||
"schedule_disabled_reason": "이 오토파일럿에는 여러 일정이 있습니다. 상세 페이지에서 수정하세요.",
|
||||
"schedule_empty": "일정이 없습니다. 이 오토파일럿은 수동으로 실행할 때만 동작합니다.",
|
||||
"schedule_add": "일정 추가",
|
||||
"error_title_required": "오토파일럿 이름을 입력하세요.",
|
||||
"error_assignee_required": "실행할 에이전트 또는 스쿼드를 선택하세요.",
|
||||
"output_modes": {
|
||||
|
||||
@@ -310,6 +310,8 @@
|
||||
"event_filter_actions_placeholder": "completed, failed",
|
||||
"event_filter_hint": "只处理匹配这些事件的 webhook。留空则接受所有事件。",
|
||||
"schedule_disabled_reason": "该自动化有多个时间表——请到详情页编辑。",
|
||||
"schedule_empty": "还没有时间表——这个自动化只在手动触发时运行。",
|
||||
"schedule_add": "添加时间表",
|
||||
"error_title_required": "请填写自动化名称。",
|
||||
"error_assignee_required": "请选择运行该自动化的智能体或小队。",
|
||||
"output_modes": {
|
||||
|
||||
Reference in New Issue
Block a user