From 3ecaa12491e4cb86dca601e3d9f8574c9346b3c1 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Tue, 28 Jul 2026 20:44:18 +0600 Subject: [PATCH 1/5] Enhance TimesField component: improve time input handling and add visual cues for removability --- .../drawers/ScheduleBuilderField.tsx | 84 +++++++++++++------ 1 file changed, 58 insertions(+), 26 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/ScheduleBuilderField.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/ScheduleBuilderField.tsx index d0456e96ad..cc67bc4268 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/ScheduleBuilderField.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/ScheduleBuilderField.tsx @@ -12,7 +12,7 @@ import { type ScheduleBuilderState, } from "@agenta/entities/gatewayTrigger" import {dayjs} from "@agenta/shared/utils" -import {Plus} from "@phosphor-icons/react" +import {Plus, X} from "@phosphor-icons/react" import { Alert, Button, @@ -21,7 +21,6 @@ import { InputNumber, Modal, Select, - Tag, TimePicker, Typography, message, @@ -363,11 +362,17 @@ function CronEditor({ } // --------------------------------------------------------------------------- -// TimesField — one or more run times as removable chips. Cron's minute and hour -// fields are independent, so a new time that would force cross-product runs is -// refused with a hint to use a second schedule. +// TimesField — one or more run times, each a live time input so "this is +// editable" needs no discovering. The last one can't be removed, so the list is +// never empty (an empty list would emit `0 0 * * *` and silently reschedule to +// midnight). Cron's minute and hour fields are independent, so a time that +// would force cross-product runs is refused with a hint to use a second +// schedule. // --------------------------------------------------------------------------- +const GRID_WARNING = + "Cron can't combine these times in one schedule — they'd trigger extra runs. Add a second schedule instead." + function TimesField({ times, onChange, @@ -376,41 +381,63 @@ function TimesField({ onChange: (times: CronTimeOfDay[]) => void }) { const [adding, setAdding] = useState(false) + const sorted = sortTimes(times) - const addTime = (t: CronTimeOfDay) => { + const commit = (next: CronTimeOfDay[]) => { setAdding(false) - if (times.some((x) => x.hour === t.hour && x.minute === t.minute)) return - const next = [...times, t] if (!timesFormCleanGrid(next)) { - message.warning( - "Cron can't combine these times in one schedule — they'd trigger extra runs. Add a second schedule instead.", - ) + message.warning(GRID_WARNING) return } onChange(sortTimes(next)) } - const removeTime = (t: CronTimeOfDay) => { - if (times.length <= 1) return - onChange(times.filter((x) => !(x.hour === t.hour && x.minute === t.minute))) + const addTime = (t: CronTimeOfDay) => { + if (sorted.some((x) => sameTime(x, t))) { + setAdding(false) + return + } + commit([...sorted, t]) + } + + // Rejected edits keep the old value: the input is controlled off `times`. + const setTimeAt = (index: number, t: CronTimeOfDay) => { + if (sorted.some((x, i) => i !== index && sameTime(x, t))) return + commit(sorted.map((x, i) => (i === index ? t : x))) + } + + const removeTimeAt = (index: number) => { + if (sorted.length <= 1) return + onChange(sorted.filter((_, i) => i !== index)) } return (
At these times (UTC)
- {sortTimes(times).map((t) => ( - 1} - onClose={(e) => { - e.preventDefault() - removeTime(t) - }} - className="!m-0 !px-2 !py-1 !text-xs" - > - {fmtTime(t)} - + {sorted.map((t, i) => ( +
+ + d && setTimeAt(i, {hour: d.hour(), minute: d.minute()}) + } + /> + {sorted.length > 1 && ( +
))} {adding ? ( d && addTime({hour: d.hour(), minute: d.minute()})} onOpenChange={(o) => !o && setAdding(false)} @@ -433,6 +461,10 @@ function TimesField({ ) } +function sameTime(a: CronTimeOfDay, b: CronTimeOfDay): boolean { + return a.hour === b.hour && a.minute === b.minute +} + function FieldLabel({children}: {children: ReactNode}) { return ( From cd33bbf9206fd62fc5104b33679a8342096d4c07 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Tue, 28 Jul 2026 22:08:20 +0600 Subject: [PATCH 2/5] Refactor workflow binding logic: enhance extraction functions and add tests for run version references --- .../drawers/TriggerScheduleDrawer.tsx | 47 ++++-- .../drawers/TriggerSubscriptionDrawer.tsx | 30 +++- .../drawers/shared/RunVersionField.tsx | 64 +++++++ .../tests/unit/runVersionReferences.test.ts | 157 ++++++++++++++++++ 4 files changed, 272 insertions(+), 26 deletions(-) create mode 100644 web/packages/agenta-entity-ui/tests/unit/runVersionReferences.test.ts diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx index ef59629a07..9fd0cc0540 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx @@ -46,7 +46,12 @@ import {useDraftMasterDetail} from "../../drawers/shared/useDraftMasterDetail" import {createWorkflowRevisionAdapter, type WorkflowRevisionSelectionResult} from "../../selection" import {ScheduleBuilderField} from "./ScheduleBuilderField" -import {RunVersionField, buildRunVersionReferences} from "./shared/RunVersionField" +import { + RunVersionField, + buildRunVersionReferences, + extractBoundWorkflowId, + isRunVersionBound, +} from "./shared/RunVersionField" // Weekly (Monday 09:00 UTC) so the builder opens on the Weekly cadence by default. const DEFAULT_CRON = "0 9 * * 1" @@ -87,6 +92,14 @@ function normalizeJson(text: string): string { } } +// Seed id for a create-mode default-bind. Variant before revision: the value is written +// back under `application_variant`, so pinning a revision id here would mislabel it. +function extractDefaultBindId( + refs: Record | null | undefined, +): string | null { + return refs?.application_variant?.id ?? refs?.application_revision?.id ?? null +} + // --------------------------------------------------------------------------- // TriggerScheduleDrawer (root) — create or edit a schedule. // @@ -467,12 +480,7 @@ function ScheduleForm({ setEnvironmentSlug(envRef.slug ?? null) setAppSlug(refs?.application?.slug ?? null) } else { - const wfId = - refs?.application_revision?.id ?? - refs?.application_variant?.id ?? - refs?.workflow_revision?.id ?? - null - setWorkflowRevId(wfId) + setWorkflowRevId(extractBoundWorkflowId(refs)) // Label is resolved from the revision id below, not stored as the raw id. } setInputsText(JSON.stringify(schedule.data?.inputs_fields ?? {}, null, 2)) @@ -487,7 +495,7 @@ function ScheduleForm({ if (isEdit) return const refs = state?.defaultReferences setAppSlug(refs?.application?.slug ?? null) - const variantId = refs?.application_variant?.id ?? refs?.application_revision?.id ?? null + const variantId = extractDefaultBindId(refs) if (!variantId) return const appId = refs?.application?.id ?? null const label = state?.defaultBoundLabel ?? appId ?? variantId @@ -511,6 +519,15 @@ function ScheduleForm({ const cronValidation = useMemo(() => validateCron(cron), [cron]) + // The binding as persisted — the picker's leaf can't represent every shape the BE accepts. + const storedReferences = schedule?.data?.references + const versionChosen = isRunVersionBound({ + bindMode, + workflowRevId, + environmentSlug, + storedReferences, + }) + // Save enables only on draft changes vs the starting point (loaded schedule in // edit, defaults in new). Normalized JSON so formatting isn't a change. const baselineSnapshot = useMemo(() => { @@ -525,11 +542,7 @@ function ScheduleForm({ enabled: isEntityActive(schedule), bindMode: envRef ? "environment" : "revision", environmentSlug: envRef?.slug ?? null, - workflowRevId: - refs?.application_revision?.id ?? - refs?.application_variant?.id ?? - refs?.workflow_revision?.id ?? - null, + workflowRevId: extractBoundWorkflowId(refs), inputs: normalizeJson(JSON.stringify(schedule.data?.inputs_fields ?? {})), }) } @@ -544,7 +557,7 @@ function ScheduleForm({ enabled: true, bindMode: "revision", environmentSlug: null, - workflowRevId: refs?.application_variant?.id ?? refs?.application_revision?.id ?? null, + workflowRevId: extractDefaultBindId(refs), inputs: normalizeJson("{}"), }) }, [isEdit, schedule, state?.defaultReferences]) @@ -592,7 +605,7 @@ function ScheduleForm({ message.error("This schedule isn't linked to an app — use Pinned (a specific revision)") return } - if (bindMode === "revision" && !workflowRevId) { + if (bindMode === "revision" && !versionChosen) { message.error("Bind a workflow") return } @@ -619,7 +632,7 @@ function ScheduleForm({ appSlug, workflowSelection, workflowRevId, - fallbackReferences: schedule?.data?.references, + fallbackReferences: storedReferences, }) const data: TriggerScheduleData = { @@ -688,6 +701,7 @@ function ScheduleForm({ appSlug, workflowRevId, workflowSelection, + versionChosen, inputsText, isEdit, schedule, @@ -702,7 +716,6 @@ function ScheduleForm({ // Per-section header state: icon tint (complete / warning / default) and a // collapsed summary of what's configured. const cronValid = cronValidation.valid - const versionChosen = bindMode === "revision" ? !!workflowRevId : !!environmentSlug const versionSummary = bindMode === "revision" ? (workflowLabel ?? resolvedRevisionName ?? undefined) diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx index 86bc45ba96..ac9e9541c1 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx @@ -68,7 +68,12 @@ import {createWorkflowRevisionAdapter, type WorkflowRevisionSelectionResult} fro import {loadRecentSamples, waitForNewDelivery} from "./shared/deliveries" import {EventSourcePicker, type SampledEvent} from "./shared/EventSourcePicker" -import {RunVersionField, buildRunVersionReferences} from "./shared/RunVersionField" +import { + RunVersionField, + buildRunVersionReferences, + extractBoundWorkflowId, + isRunVersionBound, +} from "./shared/RunVersionField" import TriggerConnectDrawer from "./TriggerConnectDrawer" // How many unsaved drafts can exist at once (config knob; see schedule drawer). @@ -111,8 +116,8 @@ function normalizeJson(text: string): string { } } -// The bound revision id can live under any of three reference keys depending on how the -// subscription was created. Read all three from one place so write/read keys can't drift. +// Seed id for a create-mode default-bind — narrower than `extractBoundWorkflowId`, which +// also accepts artifact-level ids that must not be written back under a variant key. function extractBoundRevId( refs: Record | null | undefined, ): string | null { @@ -467,6 +472,14 @@ function SubscriptionForm({ workflowMolecule.selectors.data(playgroundEntityId ?? ""), ) const workflowRevId0 = playgroundEntityId ?? null + // The binding as persisted — the picker's leaf can't represent every shape the BE accepts. + const storedReferences = subscription?.data?.references + const versionChosen = isRunVersionBound({ + bindMode, + workflowRevId, + environmentSlug, + storedReferences, + }) const revisionAdapter = useMemo(() => { if (!playgroundEntityId) return applicationRevisionAdapter return createWorkflowRevisionAdapter({ @@ -536,8 +549,7 @@ function SubscriptionForm({ setEnvironmentSlug(envRef.slug ?? null) setAppSlug(refs?.application?.slug ?? null) } else { - const wfId = extractBoundRevId(refs) - setWorkflowRevId(wfId) + setWorkflowRevId(extractBoundWorkflowId(refs)) // Don't store the raw revision id as the label — resolve a friendly name from // the molecule (resolvedRevisionName) for the picker placeholder instead. setWorkflowLabel(null) @@ -604,7 +616,7 @@ function SubscriptionForm({ enabled: isEntityActive(subscription), bindMode: envRef ? "environment" : "revision", environmentSlug: envRef?.slug ?? null, - workflowRevId: extractBoundRevId(refs), + workflowRevId: extractBoundWorkflowId(refs), inputs: subscription.data?.inputs_fields ? JSON.stringify(subscription.data.inputs_fields) : normalizeJson(DEFAULT_INPUTS_MAPPING), @@ -667,7 +679,7 @@ function SubscriptionForm({ message.error("This trigger isn't linked to an app — use Pinned (a specific revision)") return null } - if (bindMode === "revision" && !workflowRevId) { + if (bindMode === "revision" && !versionChosen) { message.error("Bind a workflow") return null } @@ -694,7 +706,7 @@ function SubscriptionForm({ appSlug, workflowSelection, workflowRevId, - fallbackReferences: subscription?.data?.references, + fallbackReferences: storedReferences, }) return { @@ -710,6 +722,7 @@ function SubscriptionForm({ environmentSlug, appSlug, workflowRevId, + versionChosen, inputsText, workflowSelection, subscription, @@ -884,7 +897,6 @@ function SubscriptionForm({ }` : eventKey : undefined - const versionChosen = bindMode === "revision" ? !!workflowRevId : !!environmentSlug const versionSummary = bindMode === "revision" ? (workflowLabel ?? resolvedRevisionName ?? undefined) diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/shared/RunVersionField.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/shared/RunVersionField.tsx index cdac99ac18..a06a49dade 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/shared/RunVersionField.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/shared/RunVersionField.tsx @@ -19,6 +19,70 @@ export interface TriggerReference { } export type TriggerReferences = Record | null | undefined +// Every key a pinned binding can live under, family by family and most specific first — +// mirrors the prefix scan in `triggers/service.py::_validate_references`. +const BIND_KEYS = ["application", "evaluator", "workflow"].flatMap((prefix) => [ + `${prefix}_revision`, + `${prefix}_variant`, + prefix, +]) + +/** First bind key whose ref satisfies `ok`. Both predicates below share this one table. */ +function findBoundRef( + references: TriggerReferences, + ok: (ref: TriggerReference) => boolean, +): TriggerReference | null { + if (!references) return null + for (const key of BIND_KEYS) { + const ref = references[key] + if (ref && ok(ref)) return ref + } + return null +} + +/** + * Does this stored family already pin a workflow? The picker only understands a leaf + * REVISION id, but the backend accepts more: an artifact or variant with no revision + * ("resolve latest at trigger time"), and refs keyed by `slug` instead of `id`. Those + * bindings can't fill the picker, so the save guard asks this instead of `!workflowRevId` + * — otherwise editing an unrelated field on a validly-bound trigger is refused. + * A deployed family is excluded: it carries `application.slug` but is not a pin, and + * treating it as one would let Pinned save while silently resending the environment ref. + */ +export function hasBoundWorkflow(references: TriggerReferences): boolean { + if (references?.environment) return false + return !!findBoundRef(references, (ref) => !!ref.id || !!ref.slug) +} + +/** + * The bound id the picker and the workflow molecule can resolve, most specific first. + * Artifact-level ids are included so an artifact-only binding still labels itself; a + * slug-only binding has no id and stays null (see {@link hasBoundWorkflow}). + */ +export function extractBoundWorkflowId(references: TriggerReferences): string | null { + return findBoundRef(references, (ref) => !!ref.id)?.id ?? null +} + +/** + * Is the run-version section answered? Pinned counts the picker's leaf OR a stored pin the + * picker can't display; Deployed needs an environment. The save guard and the section + * header must agree — if they drift, the header reads complete while save refuses. + */ +export function isRunVersionBound({ + bindMode, + workflowRevId, + environmentSlug, + storedReferences, +}: { + bindMode: RunVersionBindMode + workflowRevId?: string | null + environmentSlug?: string | null + storedReferences?: TriggerReferences +}): boolean { + if (bindMode === "environment") return !!environmentSlug + return !!workflowRevId || hasBoundWorkflow(storedReferences) +} + /** * Assemble the `data.references` family from the run-version selection — shared by the * schedule and subscription save paths. Deployed → `{environment, application(slug)}`. diff --git a/web/packages/agenta-entity-ui/tests/unit/runVersionReferences.test.ts b/web/packages/agenta-entity-ui/tests/unit/runVersionReferences.test.ts new file mode 100644 index 0000000000..635acef68b --- /dev/null +++ b/web/packages/agenta-entity-ui/tests/unit/runVersionReferences.test.ts @@ -0,0 +1,157 @@ +import {describe, expect, it} from "vitest" + +import { + buildRunVersionReferences, + extractBoundWorkflowId, + hasBoundWorkflow, + isRunVersionBound, +} from "../../src/gatewayTrigger/drawers/shared/RunVersionField" + +describe("hasBoundWorkflow", () => { + it("recognizes a leaf revision or variant binding", () => { + expect(hasBoundWorkflow({application_revision: {id: "rev-1"}})).toBe(true) + expect(hasBoundWorkflow({application_variant: {id: "var-1"}})).toBe(true) + }) + + // The backend reads an artifact-only family as "resolve latest at trigger time". + it("recognizes an artifact-level binding with no variant or revision", () => { + expect(hasBoundWorkflow({application: {id: "app-1"}})).toBe(true) + expect(hasBoundWorkflow({workflow: {id: "wf-1"}})).toBe(true) + expect(hasBoundWorkflow({evaluator_revision: {id: "ev-1"}})).toBe(true) + }) + + it("recognizes a slug-keyed binding (no ids anywhere)", () => { + expect(hasBoundWorkflow({application: {slug: "technical-writer"}})).toBe(true) + expect(hasBoundWorkflow({application_variant: {slug: "default", version: "3"}})).toBe(true) + }) + + it("reports no binding for empty, missing, or environment-only families", () => { + expect(hasBoundWorkflow(null)).toBe(false) + expect(hasBoundWorkflow(undefined)).toBe(false) + expect(hasBoundWorkflow({})).toBe(false) + expect(hasBoundWorkflow({environment: {slug: "production"}})).toBe(false) + // Present but carrying neither id nor slug. + expect(hasBoundWorkflow({application: {version: "3"}})).toBe(false) + }) + + // A deployed family carries `application.slug`; counting it as a pin would let Pinned + // save while `buildRunVersionReferences` silently resent the environment ref. + it("does not count a deployed family as a pin", () => { + expect( + hasBoundWorkflow({ + environment: {slug: "production"}, + application: {slug: "technical-writer"}, + }), + ).toBe(false) + }) +}) + +describe("extractBoundWorkflowId", () => { + it("prefers the most specific id", () => { + const refs = { + application: {id: "app-1"}, + application_variant: {id: "var-1"}, + application_revision: {id: "rev-1"}, + } + expect(extractBoundWorkflowId(refs)).toBe("rev-1") + }) + + it("falls back to the artifact id when no variant or revision is pinned", () => { + expect(extractBoundWorkflowId({application: {id: "app-1"}})).toBe("app-1") + }) + + // Same key table as hasBoundWorkflow, so evaluator families resolve an id too. + it("covers every artifact family the backend resolves", () => { + expect(extractBoundWorkflowId({evaluator_variant: {id: "ev-1"}})).toBe("ev-1") + expect(extractBoundWorkflowId({workflow: {id: "wf-1"}})).toBe("wf-1") + }) + + it("returns null when nothing carries an id", () => { + expect(extractBoundWorkflowId({application: {slug: "technical-writer"}})).toBeNull() + expect(extractBoundWorkflowId(null)).toBeNull() + }) +}) + +describe("isRunVersionBound", () => { + it("counts the picker's leaf or a stored pin in Pinned mode", () => { + expect(isRunVersionBound({bindMode: "revision", workflowRevId: "rev-1"})).toBe(true) + expect( + isRunVersionBound({ + bindMode: "revision", + storedReferences: {application: {slug: "technical-writer"}}, + }), + ).toBe(true) + expect(isRunVersionBound({bindMode: "revision"})).toBe(false) + }) + + it("rejects Pinned backed only by a stored deployed family", () => { + expect( + isRunVersionBound({ + bindMode: "revision", + storedReferences: { + environment: {slug: "production"}, + application: {slug: "technical-writer"}, + }, + }), + ).toBe(false) + }) + + it("needs an environment in Deployed mode, ignoring the picker", () => { + expect(isRunVersionBound({bindMode: "environment", environmentSlug: "production"})).toBe( + true, + ) + expect(isRunVersionBound({bindMode: "environment", workflowRevId: "rev-1"})).toBe(false) + }) +}) + +describe("buildRunVersionReferences", () => { + // An artifact-level binding must not be narrowed to a pinned revision on an unrelated edit. + it("resends stored references when there is no fresh pick", () => { + const stored = {application: {slug: "technical-writer"}} + expect( + buildRunVersionReferences({ + bindMode: "revision", + workflowSelection: null, + workflowRevId: null, + fallbackReferences: stored, + }), + ).toEqual(stored) + }) + + it("routes a fresh pick to variant or revision by the picker's leaf", () => { + const metadata = { + workflowId: "app-1", + workflowName: "writer", + variantId: "var-1", + variantName: "default", + revision: 3, + } + expect( + buildRunVersionReferences({ + bindMode: "revision", + workflowSelection: { + type: "workflowRevision", + id: "rev-1", + label: "", + path: [], + metadata, + }, + workflowRevId: "rev-1", + }), + ).toEqual({application: {id: "app-1"}, application_revision: {id: "rev-1"}}) + + expect( + buildRunVersionReferences({ + bindMode: "revision", + workflowSelection: { + type: "workflowRevision", + id: "var-1", + label: "", + path: [], + metadata, + }, + workflowRevId: "var-1", + }), + ).toEqual({application: {id: "app-1"}, application_variant: {id: "var-1"}}) + }) +}) From b87f3a98b412a58be02458d29407248e72f95db8 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Wed, 29 Jul 2026 13:20:38 +0600 Subject: [PATCH 3/5] Enhance schedule message handling: support messages payload in completion schema and improve schema readiness checks --- .../src/gatewayTrigger/core/messageInputs.ts | 11 ++++++-- .../unit/gatewayTriggerMessageInputs.test.ts | 16 ++++++++++++ .../drawers/TriggerScheduleDrawer.tsx | 25 ++++++++++++++----- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/web/packages/agenta-entities/src/gatewayTrigger/core/messageInputs.ts b/web/packages/agenta-entities/src/gatewayTrigger/core/messageInputs.ts index 20c866354d..e18f07dee4 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/core/messageInputs.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/core/messageInputs.ts @@ -66,6 +66,12 @@ export function messageContentText(content: unknown): string { return "" } +// A `messages` payload is self-describing, so it edits as chat even when the schema says +// completion — the bound agent may be unresolved, or the mapping written by the API. +function isMessagesPayload(obj: Record): boolean { + return Array.isArray(obj.messages) +} + /** Read the message out of `inputs_fields`. Empty string when absent or unparseable. */ export function getScheduleMessage( inputsText: string, @@ -77,7 +83,7 @@ export function getScheduleMessage( // would be collapsed on save). Extra top-level keys are fine — setScheduleMessage preserves // them. Not representable → "" so the caller falls back to the raw-JSON editor. const obj = parseObject(inputsText) - if (isChat) { + if (isChat || isMessagesPayload(obj)) { const messages = obj.messages if (!Array.isArray(messages) || messages.length !== 1) return "" const message = messages[0] @@ -99,7 +105,8 @@ export function setScheduleMessage( ): string { const obj = parseObject(inputsText) const trimmed = message.trim() - if (isChat) { + // Mirrors the getter's shape check so an edit writes back where it was read from. + if (isChat || isMessagesPayload(obj)) { if (trimmed) obj.messages = [{role: "user", content: message}] else delete obj.messages } else if (trimmed) { diff --git a/web/packages/agenta-entities/tests/unit/gatewayTriggerMessageInputs.test.ts b/web/packages/agenta-entities/tests/unit/gatewayTriggerMessageInputs.test.ts index b986bd62e6..081687bc70 100644 --- a/web/packages/agenta-entities/tests/unit/gatewayTriggerMessageInputs.test.ts +++ b/web/packages/agenta-entities/tests/unit/gatewayTriggerMessageInputs.test.ts @@ -27,6 +27,13 @@ describe("getScheduleMessage", () => { ) }) + // The bound agent may be unresolved (schema selectors report "completion" until it + // loads) or the mapping written by the API — the payload's own shape decides. + it("reads a messages payload even when the schema says completion", () => { + const json = JSON.stringify({messages: [{role: "user", content: "publish an article"}]}) + expect(getScheduleMessage(json, false, "message")).toBe("publish an article") + }) + it("returns empty string when absent or unparseable", () => { expect(getScheduleMessage("{}", true, "messages")).toBe("") expect(getScheduleMessage("{}", false, "query")).toBe("") @@ -99,6 +106,15 @@ describe("setScheduleMessage", () => { }) }) + // Must mirror the getter, or editing a messages payload under a completion schema + // would strand the original and add a stray `message` key. + it("writes back into messages when that is where the getter read from", () => { + const json = JSON.stringify({messages: [{role: "user", content: "old"}]}) + expect(JSON.parse(setScheduleMessage(json, "new", false, "message"))).toEqual({ + messages: [{role: "user", content: "new"}], + }) + }) + it("removes the key and collapses to {} when cleared", () => { expect(setScheduleMessage(JSON.stringify({query: "x"}), " ", false, "query")).toBe("{}") expect(setScheduleMessage(JSON.stringify({messages: [{}]}), "", true, "messages")).toBe( diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx index 9fd0cc0540..19b32fe571 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useMemo, useState, type ReactNode} from "react" +import {useCallback, useEffect, useMemo, useRef, useState, type ReactNode} from "react" import { appEnvironmentsQueryAtomFamily, @@ -732,6 +732,7 @@ function ScheduleForm({ // "completion" (flat named inputs). The composer writes to `messages` for chat, or // the first string input from the schema (fallback "message") for completion. const schemaSourceId = playgroundEntityId ?? workflowRevId ?? "" + const schemaReady = !!(playgroundEntityId ? playgroundWorkflow : resolvedRevData) const isChatInput = useAtomValue(workflowMolecule.selectors.executionMode(schemaSourceId)) === "chat" const agentInputSchema = useAtomValue(workflowMolecule.selectors.inputSchema(schemaSourceId)) @@ -868,6 +869,7 @@ function ScheduleForm({ isEdit={isEdit} isChat={isChatInput} primaryKey={primaryInputKey} + schemaReady={schemaReady} disabled={isMutating} /> @@ -1049,6 +1051,7 @@ function MessageComposer({ isEdit, isChat, primaryKey, + schemaReady, disabled, }: { inputsText: string @@ -1056,16 +1059,26 @@ function MessageComposer({ isEdit: boolean isChat: boolean primaryKey: string + /** Has the bound agent's schema resolved? `isChat`/`primaryKey` are meaningless until it has. */ + schemaReady: boolean disabled?: boolean }) { // Open in Advanced (raw JSON) when editing a saved mapping the simple composer can't // reproduce, so the first edit doesn't collapse it to a single message. - const [rawMode, setRawMode] = useState( - () => + const [rawMode, setRawMode] = useState(false) + // Deferred to the first render with a resolved schema: `executionMode` reports + // "completion" while the agent loads, which used to latch raw mode on editable payloads. + const decided = useRef(false) + useEffect(() => { + if (decided.current || !schemaReady) return + decided.current = true + if ( isEdit && - !!inputsText.trim() && - getScheduleMessage(inputsText, isChat, primaryKey) === "", - ) + inputsText.trim() && + getScheduleMessage(inputsText, isChat, primaryKey) === "" + ) + setRawMode(true) + }, [schemaReady, isEdit, inputsText, isChat, primaryKey]) const rawValid = useMemo(() => { const t = inputsText.trim() From f95d06a688bc22c7389498fc0ad8f8082a4c54ce Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Wed, 29 Jul 2026 13:23:29 +0600 Subject: [PATCH 4/5] Refactor environment handling: temporarily hide Deployed option in schedule and subscription forms --- .../gatewayTrigger/drawers/TriggerScheduleDrawer.tsx | 10 ++++++++++ .../drawers/TriggerSubscriptionDrawer.tsx | 8 ++++++++ .../drawers/shared/RunVersionField.tsx | 12 ++++++++---- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx index 19b32fe571..53ad28e5d5 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx @@ -1,9 +1,11 @@ import {useCallback, useEffect, useMemo, useRef, useState, type ReactNode} from "react" +/* Unused while the Deployed option is hidden — restore with the call site below. import { appEnvironmentsQueryAtomFamily, environmentsListQueryAtomFamily, } from "@agenta/entities/environment" +*/ import { describeCron, getScheduleMessage, @@ -402,8 +404,10 @@ function ScheduleForm({ const [bindMode, setBindMode] = useState<"revision" | "environment">("revision") const [environmentSlug, setEnvironmentSlug] = useState(null) const [appSlug, setAppSlug] = useState(null) + /* Unused while the Deployed option is hidden — restore with the call site below. const envQuery = useAtomValue(environmentsListQueryAtomFamily(false)) const environments = envQuery.data?.environments ?? [] + */ // Resolve the bound revision id to a human label (edit-mode prefill stores only // the id) — app name / variant name · vN. These are sync atoms (null for unknown). @@ -446,6 +450,7 @@ function ScheduleForm({ // Environment options: in a playground, scope to the environments this agent is // actually deployed to (not every project environment); settings lists them all. + /* Unused while the Deployed option is hidden — restore with the call site below. const appIdForEnv = playgroundEntityId ? (playgroundWorkflow?.workflow_id ?? playgroundEntityId) : "" @@ -464,6 +469,7 @@ function ScheduleForm({ : (d.name ?? d.slug ?? ""), })) }, [playgroundEntityId, environments, appDeployments.data]) + */ // Prefill from the freshly-fetched schedule (edit mode). useEffect(() => { @@ -840,6 +846,9 @@ function ScheduleForm({ label = label ? `${label} · v${m.revision}` : `v${m.revision}` setWorkflowLabel(label || selection.label) }} + hideEnvironment + /* Deployed option temporarily hidden — drop `hideEnvironment` + and uncomment to restore. envOptions={envOptions} envLoading={ playgroundEntityId ? appDeployments.isLoading : envQuery.isLoading @@ -851,6 +860,7 @@ function ScheduleForm({ ? "This agent isn't deployed to any environment yet." : undefined } + */ /> diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx index ac9e9541c1..9d7fda56ea 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx @@ -1,9 +1,11 @@ import {useCallback, useEffect, useMemo, useRef, useState, type ReactNode} from "react" +/* Unused while the Deployed option is hidden — restore with the call site below. import { appEnvironmentsQueryAtomFamily, environmentsListQueryAtomFamily, } from "@agenta/entities/environment" +*/ import { compileMessageTemplate, getScheduleMessagePreview, @@ -498,6 +500,7 @@ function SubscriptionForm({ workflowMolecule.selectors.artifactName(workflowRevId ?? ""), ) + /* Unused while the Deployed option is hidden — restore with the call site below. const envQuery = useAtomValue(environmentsListQueryAtomFamily(false)) const environments = envQuery.data?.environments ?? [] const appIdForEnv = playgroundEntityId @@ -518,6 +521,7 @@ function SubscriptionForm({ : (d.name ?? d.slug ?? ""), })) }, [playgroundEntityId, environments, appDeployments.data]) + */ const {subscriptions} = useTriggerSubscriptions() const alreadySubscribed = useMemo( @@ -1040,6 +1044,9 @@ function SubscriptionForm({ label = label ? `${label} · v${m.revision}` : `v${m.revision}` setWorkflowLabel(label || selection.label) }} + hideEnvironment + /* Deployed option temporarily hidden — drop `hideEnvironment` + and uncomment to restore. envOptions={envOptions} envLoading={ playgroundEntityId ? appDeployments.isLoading : envQuery.isLoading @@ -1051,6 +1058,7 @@ function SubscriptionForm({ ? "This agent isn't deployed to any environment yet." : undefined } + */ /> diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/shared/RunVersionField.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/shared/RunVersionField.tsx index a06a49dade..30bab0c7aa 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/shared/RunVersionField.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/shared/RunVersionField.tsx @@ -141,6 +141,7 @@ export function RunVersionField({ revisionPlaceholder, onRevisionSelect, revisionHint = "Runs one exact variant + revision.", + hideEnvironment = false, envOptions, envLoading, environmentSlug, @@ -155,10 +156,13 @@ export function RunVersionField({ revisionPlaceholder?: string onRevisionSelect: (selection: WorkflowRevisionSelectionResult) => void revisionHint?: string - envOptions: {value: string; label: string}[] + /** TEMPORARY: drop the Deployed option, leaving a Pinned-only rail. Set by the trigger + * drawers; the tool "Reference by" control still offers both. */ + hideEnvironment?: boolean + envOptions?: {value: string; label: string}[] envLoading?: boolean environmentSlug?: string | null - onEnvironmentChange: (slug: string) => void + onEnvironmentChange?: (slug: string) => void envNotFound?: React.ReactNode envHint?: string /** Left-rail width (Tailwind class). Override to align with a sibling section's rail. */ @@ -168,13 +172,13 @@ export function RunVersionField({ onBindModeChange(v as RunVersionBindMode)} railWidth={railWidth} > - {bindMode === "revision" ? ( + {bindMode === "revision" || hideEnvironment ? ( <> {revisionHint} From 9759558a2d5b520a9e9dceebeb36a87a892cf071 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Wed, 29 Jul 2026 14:16:41 +0600 Subject: [PATCH 5/5] Refactor MessageComposer component: remove unused props and improve user message handling --- .../drawers/TriggerScheduleDrawer.tsx | 47 ++++++++----------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx index 53ad28e5d5..ba39bf0857 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useMemo, useRef, useState, type ReactNode} from "react" +import {useCallback, useEffect, useMemo, useState, type ReactNode} from "react" /* Unused while the Deployed option is hidden — restore with the call site below. import { @@ -738,7 +738,6 @@ function ScheduleForm({ // "completion" (flat named inputs). The composer writes to `messages` for chat, or // the first string input from the schema (fallback "message") for completion. const schemaSourceId = playgroundEntityId ?? workflowRevId ?? "" - const schemaReady = !!(playgroundEntityId ? playgroundWorkflow : resolvedRevData) const isChatInput = useAtomValue(workflowMolecule.selectors.executionMode(schemaSourceId)) === "chat" const agentInputSchema = useAtomValue(workflowMolecule.selectors.inputSchema(schemaSourceId)) @@ -876,10 +875,8 @@ function ScheduleForm({ @@ -1052,43 +1049,25 @@ function WindowField({ // MessageComposer — friendly "what should the agent do?" message that maps to the // agent's primary input (`messages` for chat agents, else a schema string input). // "Advanced — raw JSON" swaps to a JSON editor over the full `inputs_fields`; only -// one editor is mounted at a time so the message and JSON never desync. +// one editor is mounted at a time so the message and JSON never desync. Always opens +// on the message — a mapping the composer can't reproduce warns instead of switching. // --------------------------------------------------------------------------- function MessageComposer({ inputsText, onChange, - isEdit, isChat, primaryKey, - schemaReady, disabled, }: { inputsText: string onChange: (next: string) => void - isEdit: boolean isChat: boolean primaryKey: string - /** Has the bound agent's schema resolved? `isChat`/`primaryKey` are meaningless until it has. */ - schemaReady: boolean disabled?: boolean }) { - // Open in Advanced (raw JSON) when editing a saved mapping the simple composer can't - // reproduce, so the first edit doesn't collapse it to a single message. + // Always opens on the message; raw JSON is opt-in via "Advanced" below. const [rawMode, setRawMode] = useState(false) - // Deferred to the first render with a resolved schema: `executionMode` reports - // "completion" while the agent loads, which used to latch raw mode on editable payloads. - const decided = useRef(false) - useEffect(() => { - if (decided.current || !schemaReady) return - decided.current = true - if ( - isEdit && - inputsText.trim() && - getScheduleMessage(inputsText, isChat, primaryKey) === "" - ) - setRawMode(true) - }, [schemaReady, isEdit, inputsText, isChat, primaryKey]) const rawValid = useMemo(() => { const t = inputsText.trim() @@ -1134,6 +1113,8 @@ function MessageComposer({ } const message = getScheduleMessage(inputsText, isChat, primaryKey) + // The composer writes a single user message, so anything richer would be lost on edit. + const wouldReplace = !message && !!inputsText.trim() && inputsText.trim() !== "{}" return (
- - Sent to the agent{" "} - {isChat ? "as the user message" : `as the "${primaryKey}" input`} on each run. + + {wouldReplace ? ( + "This mapping is richer than one message — typing here replaces it. Edit it under Advanced." + ) : ( + <> + Sent to the agent{" "} + {isChat ? "as the user message" : `as the "${primaryKey}" input`} on + each run. + + )}