diff --git a/packages/app/src/features/jobs/editor-queries.ts b/packages/app/src/features/jobs/editor-queries.ts
index 23f15ad..d1e97e9 100644
--- a/packages/app/src/features/jobs/editor-queries.ts
+++ b/packages/app/src/features/jobs/editor-queries.ts
@@ -14,7 +14,7 @@ export const availabilityQuery = queryOptions({ queryKey: ["editor", "availabili
const { data, error } = await api.GET("/api/v1/job-trigger-availability", { signal });
if (!data || error) throw new Error(messageOf(error)); return data;
}, staleTime: 60_000 });
-export const selectorQuery = (q: string, cursor?: string) => queryOptions({ queryKey: ["jobs", "selector", q, cursor], queryFn: async ({ signal }) => {
- const { data, error } = await api.GET("/api/v1/job-selector", { signal, params: { query: { q, cursor, limit: 30 } } });
+export const selectorQuery = (q: string, cursor?: string, projectId?: string) => queryOptions({ queryKey: ["jobs", "selector", projectId, q, cursor], queryFn: async ({ signal }) => {
+ const { data, error } = await api.GET("/api/v1/job-selector", { signal, params: { query: { q, cursor, limit: 30, projectId } } });
if (!data || error) throw new Error(messageOf(error)); return data;
}, staleTime: 30_000 });
diff --git a/packages/app/src/features/jobs/editor.tsx b/packages/app/src/features/jobs/editor.tsx
index 0f52027..6630e03 100644
--- a/packages/app/src/features/jobs/editor.tsx
+++ b/packages/app/src/features/jobs/editor.tsx
@@ -95,7 +95,7 @@ function EditorForm({ initial }: { initial?: Job }) {
Skills
Ordered global instructions are composed before this job context. Bodies remain literal.
{(draft.skillIds ?? []).map((id, index) => { const skill = skills.data?.items.find(item => item.id === id); return
{index + 1}. {skill?.key ?? "Unavailable skill"}
; })}
Create or edit skills
Prompt and context
Write instructions using context supplied by your triggers.
update("promptTemplate", value)} triggers={draft.triggers} />
update("runNameTemplate", value)} triggers={draft.triggers} />
Template variables{metadata.error && {metadata.error.message}
}{Object.entries(metadata.data ?? {}).map(([kind, fields]) => {kind}
{fields.map(field => {field.path} — {field.description} )}
)}
- Triggers
Choose the events that start this job.
update("triggers", value)} onValidityChange={setInvalidForm} />
+ Triggers
Choose the events that start this job.
update("triggers", value)} onValidityChange={setInvalidForm} />
{initial?.triggers.map(trigger => trigger.kind === "webhook" && trigger.config.provider === "cloudflareTail" && trigger.config.destination ? {trigger.slug} · Tail webhook destination
{trigger.config.destination} : null)}
{save.error &&
{save.error.message}
}
{!initial && }
diff --git a/packages/app/src/features/jobs/lifecycle-selector.tsx b/packages/app/src/features/jobs/lifecycle-selector.tsx
index 742c00e..6f2628f 100644
--- a/packages/app/src/features/jobs/lifecycle-selector.tsx
+++ b/packages/app/src/features/jobs/lifecycle-selector.tsx
@@ -2,9 +2,9 @@ import { Button, Input, Label } from "../../shared/ui";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { selectorQuery } from "./editor-queries";
-export function LifecycleSelector({ selected, onChange }: { selected: string[]; onChange: (ids: string[]) => void }) {
+export function LifecycleSelector({ selected, onChange, projectId }: { selected: string[]; onChange: (ids: string[]) => void; projectId?: string }) {
const [q, setQ] = useState(""), [cursor, setCursor] = useState();
- const jobs = useQuery(selectorQuery(q, cursor));
+ const jobs = useQuery(selectorQuery(q, cursor, projectId));
return )}
diff --git a/packages/app/test/editor.browser.test.mjs b/packages/app/test/editor.browser.test.mjs
index 9fe3a39..f71eb8e 100644
--- a/packages/app/test/editor.browser.test.mjs
+++ b/packages/app/test/editor.browser.test.mjs
@@ -35,6 +35,37 @@ test("stale editor reconciles non-overlapping fields, requires explicit conflict
await page.getByRole("status").filter({ hasText: "Job settings saved." }).waitFor();
assert.equal(writes[0].expectedUpdatedAt, initial.updatedAt); assert.equal(writes[1].expectedUpdatedAt, "2026-10-09T13:00:00.000Z"); assert.equal(writes[1].triggers[0].id, triggerId); assert.equal(writes[1].triggers[0].slug, "trigger-1"); assert.equal(writes[1].promptTemplate, "My prompt"); await context.close();
});
+test("All runs complete source jobs stay within the edited job project during search and save", async () => {
+ const { context, page } = await setup();
+ const projectA = "00000000-0000-4000-8000-000000000020", projectB = "00000000-0000-4000-8000-000000000021";
+ const sameProjectJob = "00000000-0000-4000-8000-000000000022", otherProjectJob = "00000000-0000-4000-8000-000000000023";
+ let current = { ...structuredClone(initial), projectId: projectA, triggers: [{ ...initial.triggers[0], kind: "jobIdle", config: { sourceJobIds: [] } }] }, saved;
+ const selectorRequests = [];
+ await page.route(`**/api/v1/jobs/${id}`, route => {
+ if (route.request().method() === "GET") return route.fulfill({ json: current });
+ saved = route.request().postDataJSON(); current = { ...current, ...saved }; return route.fulfill({ json: current });
+ });
+ await page.route("**/api/v1/job-selector?**", route => {
+ const url = new URL(route.request().url()); selectorRequests.push(url);
+ const projectId = url.searchParams.get("projectId"), q = url.searchParams.get("q") ?? "";
+ const allJobs = [
+ { id: sameProjectJob, projectId: projectA, name: "Alpha source", slug: "alpha-source", enabled: true },
+ { id: otherProjectJob, projectId: projectB, name: "Alpha other", slug: "alpha-other", enabled: true },
+ ];
+ return route.fulfill({ json: { items: allJobs.filter(job => job.projectId === projectId && job.name.toLowerCase().includes(q.toLowerCase())).map(({ projectId: _projectId, ...job }) => job), nextCursor: null } });
+ });
+ await page.goto(origin + `/jobs/${id}/edit`);
+ await page.getByLabel("Search source jobs").fill("Alpha");
+ await page.getByLabel("Alpha source", { exact: true }).check();
+ assert.equal(await page.getByLabel("Alpha other", { exact: true }).count(), 0);
+ await page.getByRole("status").filter({ hasText: "Job settings saved." }).waitFor();
+ assert.ok(selectorRequests.length > 0);
+ assert.ok(selectorRequests.every(url => url.searchParams.get("projectId") === projectA));
+ assert.ok(selectorRequests.some(url => url.searchParams.get("q") === "Alpha"));
+ assert.deepEqual(saved.triggers[0].config.sourceJobIds, [sameProjectJob]);
+ assert.equal(projectA, current.projectId); assert.notEqual(projectA, projectB);
+ await context.close();
+});
test("create uses bounded lifecycle selector, lazy provider reads, schedule preview and typed form submission on mobile", async () => {
const { context, page } = await setup({ viewport: { width: 390, height: 844 } }); let body, providerReads = 0; const selectors = [];
const projectId = "00000000-0000-4000-8000-000000000010";