From 89622b0aa2206e8d63429d900d092823e3b001dc Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 16 Jun 2026 11:57:06 +0100 Subject: [PATCH 1/8] Improves the session definition --- apps/webapp/app/components/BlankStatePanels.tsx | 10 ++++------ .../route.tsx | 5 +---- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 4d448e1a1d..3e4a43ec9b 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -207,16 +207,14 @@ export function SessionsNone() { } > - A session is a pair of streams: input for incoming user messages, and output for - everything the agent produces, including AI generation parts (text, reasoning, tool - calls, etc.) and any custom data parts your task emits. Sessions also orchestrate the - execution of agent runs, so a single conversation can span many task triggers. + A session is a stateful execution of an agent. It includes two-way streaming and durable + compute. A session can have multiple “Runs” associated with it. The easiest way to create one is to trigger a chat.agent task, which is built on sessions and handles the chat turn loop for you. You can also call{" "} - sessions.start() directly for non-chat patterns like agent - inboxes, approval flows, or server-to-server streaming. + sessions.start() directly for non-chat patterns like agent inboxes, + approval flows, or server-to-server streaming. ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx index 1d193d2979..1cae8b3c51 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx @@ -129,10 +129,7 @@ function SessionsHelpTooltip() {
What is a session? - A session is a pair of streams: input for incoming user messages, and output for - everything the agent produces, including AI generation parts (text, reasoning, tool - calls, etc.) and any custom data parts your task emits. Sessions also orchestrate the - execution of agent runs, so a single conversation can span many task triggers. + A session is a stateful execution of an agent. It includes two-way streaming and durable compute. A session can have multiple “Runs” associated with it.
From 3d33e217dd9651b2b1af5f7bd0ee71cae8eddd3d Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 16 Jun 2026 13:33:36 +0100 Subject: [PATCH 2/8] Separates schedules table into tabs + fixes to the imperative create/delete flow --- .../schedules/ScheduleInspector.tsx | 111 ++++-- .../route.tsx | 52 ++- .../route.tsx | 368 +++++++++++++++--- .../route.tsx | 137 ++++--- 4 files changed, 512 insertions(+), 156 deletions(-) diff --git a/apps/webapp/app/components/schedules/ScheduleInspector.tsx b/apps/webapp/app/components/schedules/ScheduleInspector.tsx index c2cc600247..abe25bc178 100644 --- a/apps/webapp/app/components/schedules/ScheduleInspector.tsx +++ b/apps/webapp/app/components/schedules/ScheduleInspector.tsx @@ -6,7 +6,7 @@ import { TrashIcon, } from "@heroicons/react/20/solid"; import { DialogDescription } from "@radix-ui/react-dialog"; -import { Form, useLocation } from "@remix-run/react"; +import { type FetcherWithComponents, Form, useLocation } from "@remix-run/react"; import { type ReactNode } from "react"; import { InlineCode } from "~/components/code/InlineCode"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; @@ -76,9 +76,22 @@ type Props = { * is rendered somewhere else (e.g. in a sheet on a different page). */ actionPath?: string; + /** When set, Edit calls back instead of navigating to the standalone edit page. */ + onEdit?: () => void; + /** Submits enable/disable via this fetcher with `_format=json` so the host stays put. */ + activeToggleFetcher?: FetcherWithComponents; + /** Submits delete via this fetcher with `_format=json` so the host stays put. */ + deleteFetcher?: FetcherWithComponents; }; -export function ScheduleInspector({ schedule, headerActions, actionPath }: Props) { +export function ScheduleInspector({ + schedule, + headerActions, + actionPath, + onEdit, + activeToggleFetcher, + deleteFetcher, +}: Props) { const location = useLocation(); const organization = useOrganization(); const project = useProject(); @@ -91,7 +104,7 @@ export function ScheduleInspector({ schedule, headerActions, actionPath }: Props
@@ -244,30 +257,38 @@ export function ScheduleInspector({ schedule, headerActions, actionPath }: Props
{isImperative && ( -
+
-
- -
+ {(() => { + const ToggleForm = activeToggleFetcher?.Form ?? Form; + const isSubmitting = activeToggleFetcher?.state === "submitting"; + return ( + + {activeToggleFetcher ? : null} + + + ); + })()} @@ -276,31 +297,45 @@ export function ScheduleInspector({ schedule, headerActions, actionPath }: Props Are you sure you want to delete this schedule? This can't be reversed. -
- -
+ {(() => { + const DeleteForm = deleteFetcher?.Form ?? Form; + const isSubmitting = deleteFetcher?.state === "submitting"; + return ( + + {deleteFetcher ? : null} + + + ); + })()}
- - Edit schedule - + {onEdit ? ( + + ) : ( + + Edit schedule… + + )}
)} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx index 290ea35db8..97214472a1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx @@ -16,7 +16,6 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server"; import { requireUserId } from "~/services/session.server"; import { v3EnvironmentPath, v3ScheduleParams, v3SchedulePath } from "~/utils/pathBuilder"; -import { throwNotFound } from "~/utils/httpErrors"; import { DeleteTaskScheduleService } from "~/v3/services/deleteTaskSchedule.server"; import { SetActiveOnTaskScheduleService } from "~/v3/services/setActiveOnTaskSchedule.server"; @@ -45,11 +44,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { environmentId: environment.id, }); - if (!result) { - throwNotFound("Schedule not found"); - } - - return typedjson({ schedule: result.schedule }); + // Return null (not a 404 throw) so fetcher-driven hosts (e.g. the sheet + // running this loader after a delete-in-flight) don't surface a + // page-level error boundary. The standalone Page below renders a + // not-found message when `schedule` is null. + return typedjson({ schedule: result?.schedule ?? null }); }; const schema = z.discriminatedUnion("action", [ @@ -76,6 +75,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json(submission); } + // `_format=json` → return JSON instead of redirecting; caller stays put. + const wantsJson = formData.get("_format") === "json"; + const project = await prisma.project.findFirst({ where: { slug: projectParam, @@ -104,12 +106,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { userId, friendlyId: scheduleParam, }); + if (wantsJson) { + return json({ ok: true as const, message: `${scheduleParam} deleted` }); + } return redirectWithSuccessMessage( v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, `${scheduleParam} deleted` ); } catch (e) { + const message = `${scheduleParam} could not be deleted: ${ + e instanceof Error ? e.message : JSON.stringify(e) + }`; + if (wantsJson) { + return json({ ok: false as const, message }, { status: 500 }); + } return redirectWithErrorMessage( v3SchedulePath( { slug: organizationSlug }, @@ -118,9 +129,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { { friendlyId: scheduleParam } ), request, - `${scheduleParam} could not be deleted: ${ - e instanceof Error ? e.message : JSON.stringify(e) - }` + message ); } } @@ -135,6 +144,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { friendlyId: scheduleParam, active, }); + if (wantsJson) { + return json({ ok: true as const, active }); + } return redirectWithSuccessMessage( v3SchedulePath( { slug: organizationSlug }, @@ -146,6 +158,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { `${scheduleParam} ${active ? "enabled" : "disabled"}` ); } catch (e) { + const message = e instanceof Error ? e.message : JSON.stringify(e); + if (wantsJson) { + return json({ ok: false as const, message }, { status: 500 }); + } return redirectWithErrorMessage( v3SchedulePath( { slug: organizationSlug }, @@ -154,9 +170,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { { friendlyId: scheduleParam } ), request, - `${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${ - e instanceof Error ? e.message : JSON.stringify(e) - }` + `${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${message}` ); } } @@ -170,6 +184,20 @@ export default function Page() { const project = useProject(); const environment = useEnvironment(); + if (!schedule) { + return ( +
+

Schedule not found.

+ + Back to tasks + +
+ ); + } + return ( { environmentId: environment.id, tasks: [task.slug], page: schedulesPage, + pageSize: 25, }) .catch(() => null); @@ -202,10 +211,7 @@ export default function Page() { const closeSchedule = useCallback(() => search.del("schedule"), [search]); const isCreatingSchedule = search.has("createSchedule"); - const openCreateSchedule = useCallback( - () => search.replace({ createSchedule: "1" }), - [search] - ); + const openCreateSchedule = useCallback(() => search.replace({ createSchedule: "1" }), [search]); const closeCreateSchedule = useCallback(() => search.del("createSchedule"), [search]); // Schedules add-on / quota state — drives the bottom usage bar and the @@ -240,9 +246,9 @@ export default function Page() {
{/* Top bar — title on the left; actions + TimeFilter + pagination on the right. h-10 matches the right-hand sidebar header height. */} -
+
Runs -
+
void; }) { const fetcher = useTypedFetcher(); + // Embedded create — stays on this page via `_format=json`. + const createFetcher = useFetcher<{ ok: boolean; message?: string }>(); + const toast = useToast(); + const revalidator = useRevalidator(); + // `useRevalidator()` and `onClose` change identity every render — guard + // against the dep churn so we only handle each response once. + const handledCreateRef = useRef(null); const newPath = v3NewSchedulePath(organization, project, environment); useEffect(() => { if (open) fetcher.load(newPath); }, [open, newPath]); + // Toast + close + revalidate so the new schedule appears. + useEffect(() => { + const data = createFetcher.data; + if (createFetcher.state !== "idle" || !data) return; + if (handledCreateRef.current === data) return; + handledCreateRef.current = data; + if (data.ok) { + toast.success(data.message ?? "Schedule created"); + revalidator.revalidate(); + onClose(); + } else if (data.message) { + toast.error(data.message); + } + }, [createFetcher.state, createFetcher.data, toast, revalidator, onClose]); + const data = fetcher.data; const isLoading = fetcher.state === "loading" || (open && !data); @@ -512,6 +540,8 @@ function CreateScheduleSheet({ possibleTimezones={data.possibleTimezones} showGenerateField={data.showGenerateField} defaultTaskIdentifier={defaultTaskIdentifier} + onCancel={onClose} + submitFetcher={createFetcher} /> )} @@ -532,17 +562,87 @@ function ScheduleSheet({ environment: ReturnType; onClose: () => void; }) { - const fetcher = useTypedFetcher(); + const detailFetcher = useTypedFetcher(); + const editFetcher = useTypedFetcher(); + // Embedded enable/disable — stays in the sheet via `_format=json`. + const activeToggleFetcher = useFetcher<{ ok: boolean; active?: boolean }>(); + // Embedded update submission — same idea. + const updateFetcher = useFetcher<{ ok: boolean; message?: string }>(); + // Embedded delete submission — same idea. + const deleteFetcher = useFetcher<{ ok: boolean; message?: string }>(); + const toast = useToast(); + const revalidator = useRevalidator(); + // Dedupe response handling against unstable deps (revalidator/onClose). + const handledToggleRef = useRef(null); + const handledUpdateRef = useRef(null); + const handledDeleteRef = useRef(null); + const [mode, setMode] = useState<"inspect" | "edit">("inspect"); + const detailPath = openScheduleId ? v3SchedulePath(organization, project, environment, { friendlyId: openScheduleId }) : undefined; + const editPath = openScheduleId + ? v3EditSchedulePath(organization, project, environment, { friendlyId: openScheduleId }) + : undefined; + // Always reopen in inspect mode. useEffect(() => { - if (detailPath) fetcher.load(detailPath); + setMode("inspect"); + }, [openScheduleId]); + + useEffect(() => { + if (detailPath) detailFetcher.load(detailPath); }, [detailPath]); - const schedule = fetcher.data?.schedule; - const isLoading = fetcher.state === "loading" || (!!openScheduleId && !schedule); + useEffect(() => { + if (mode === "edit" && editPath) editFetcher.load(editPath); + }, [mode, editPath]); + + // Reload inspector data so Enable/Disable label flips. + useEffect(() => { + const data = activeToggleFetcher.data; + if (activeToggleFetcher.state !== "idle" || !data?.ok || !detailPath) return; + if (handledToggleRef.current === data) return; + handledToggleRef.current = data; + detailFetcher.load(detailPath); + }, [activeToggleFetcher.state, activeToggleFetcher.data, detailPath]); + + // Toast + back to inspect + reload so the inspector reflects the update. + useEffect(() => { + const data = updateFetcher.data; + if (updateFetcher.state !== "idle" || !data) return; + if (handledUpdateRef.current === data) return; + handledUpdateRef.current = data; + if (data.ok) { + toast.success(data.message ?? "Schedule updated"); + setMode("inspect"); + if (detailPath) detailFetcher.load(detailPath); + } else if (data.message) { + toast.error(data.message); + } + }, [updateFetcher.state, updateFetcher.data, detailPath, toast]); + + // Toast + close + revalidate so the deleted row disappears. + useEffect(() => { + const data = deleteFetcher.data; + if (deleteFetcher.state !== "idle" || !data) return; + if (handledDeleteRef.current === data) return; + handledDeleteRef.current = data; + if (data.ok) { + toast.success(data.message ?? "Schedule deleted"); + revalidator.revalidate(); + onClose(); + } else if (data.message) { + toast.error(data.message); + } + }, [deleteFetcher.state, deleteFetcher.data, toast, revalidator, onClose]); + + const schedule = detailFetcher.data?.schedule; + const isDetailLoading = + detailFetcher.state === "loading" || (!!openScheduleId && !schedule); + const editData = editFetcher.data; + const isEditLoading = + mode === "edit" && (editFetcher.state === "loading" || !editData); return ( !open && onClose()}> @@ -551,10 +651,30 @@ function ScheduleSheet({ className="w-[480px] max-w-none border-l border-grid-dimmed bg-background-bright p-0 sm:max-w-none" onOpenAutoFocus={(e) => e.preventDefault()} > - {isLoading || !schedule ? ( + {mode === "edit" ? ( + isEditLoading || !editData ? ( + + ) : ( + setMode("inspect")} + submitFetcher={updateFetcher} + /> + ) + ) : isDetailLoading || !schedule ? ( ) : ( - + setMode("edit")} + activeToggleFetcher={activeToggleFetcher} + deleteFetcher={deleteFetcher} + /> )} @@ -572,9 +692,19 @@ function ScheduledTaskDetailSidebar({ LoaderData, "scheduleList" >) { + const sortedSchedules = useMemo(() => { + if (!scheduleList) return []; + // DECLARATIVE first; createdAt-desc within each type (stable sort). + return [...scheduleList.schedules].sort((a, b) => { + if (a.type === b.type) return 0; + return a.type === "DECLARATIVE" ? -1 : 1; + }); + }, [scheduleList?.schedules]); + const firstSchedule = sortedSchedules[0]; + const [activeTab, setActiveTab] = useState<"overview" | "schedules">("overview"); return ( -
-
+
+
{task.slug} @@ -590,48 +720,136 @@ function ScheduledTaskDetailSidebar({ Test schedule
-
- - - Identifier - - - - - - File path - - - - - - Type - - Scheduled task - - - - Created - - - - - -
- Schedules -
- {scheduleList ? ( +
+ + setActiveTab("overview")} + shortcut={{ key: "o" }} + > + Overview + + setActiveTab("schedules")} + shortcut={{ key: "s" }} + > + Schedules + + + {activeTab === "schedules" && scheduleList && scheduleList.totalPages > 1 ? ( +
+ +
+ ) : null} +
+ {activeTab === "overview" ? ( +
+ + + Identifier + + + + + + File path + + + + + + Schedule ID + + {firstSchedule ? ( + + ) : ( + + )} + + + + CRON + + {firstSchedule ? ( +
+ {firstSchedule.cron} + {firstSchedule.cronDescription} +
+ ) : ( + + )} +
+
+ + Created + + + + + + Next run + + {firstSchedule ? ( + + ) : ( + + )} + + + + Last run + + {firstSchedule?.lastRun ? ( + + ) : ( + Never + )} + + + + Status + + {firstSchedule ? ( + + ) : ( + + )} + + +
+ {scheduleList && sortedSchedules.length === 0 ? ( +
+ +
+ ) : null} +
+ ) : ( +
+ {scheduleList ? ( + sortedSchedules.length === 0 ? ( +
+ +
+ ) : ( - ) : ( - - )} -
+ ) + ) : ( + + )}
-
+ )}
); } @@ -652,14 +870,16 @@ function SchedulesMiniTable({ schedules, variant, onSelectSchedule, + showTopBorder = true, }: { schedules: ScheduleRow[]; variant?: TableVariant; onSelectSchedule: (friendlyId: string) => void; + showTopBorder?: boolean; }) { if (schedules.length === 0) { return ( - +
@@ -672,7 +892,7 @@ function SchedulesMiniTable({ } return ( -
+
Schedule ID @@ -839,6 +1059,38 @@ function ActivityChartSkeleton() { ); } +function NoSchedulesAttachedPanel() { + return ( + + Read the docs + + } + > + + Scheduled tasks only run automatically when a schedule is attached. There are two types: + + + Declarative — defined directly on your{" "} + schedules.task and synced when you run dev or deploy. + + + Imperative — created dynamically from + the dashboard or via the SDK with schedules.create(). + + + ); +} + function TableLoading() { return (
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index 37686eec81..276b6c8133 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -1,7 +1,13 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; -import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react"; +import { + type FetcherWithComponents, + Form, + useActionData, + useLocation, + useNavigation, +} from "@remix-run/react"; import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { useVirtualizer } from "@tanstack/react-virtual"; import { parseExpression } from "cron-parser"; @@ -75,6 +81,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json(submission); } + // `_format=json` → return JSON instead of redirecting; caller toasts. + const wantsJson = formData.get("_format") === "json"; + try { //first check that the user has access to the project const project = await prisma.project.findUnique({ @@ -98,15 +107,25 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const createSchedule = new UpsertTaskScheduleService(); const result = await createSchedule.call(project.id, submission.value); + const message = + submission.value?.friendlyId === result.id ? "Schedule updated" : "Schedule created"; + + if (wantsJson) { + return json({ ok: true as const, message }); + } + return redirectWithSuccessMessage( v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, - submission.value?.friendlyId === result.id ? "Schedule updated" : "Schedule created" + message ); } catch (error: any) { logger.error("Failed to create schedule", error); const errorMessage = `Something went wrong. Please try again.`; + if (wantsJson) { + return json({ ok: false as const, message: errorMessage }, { status: 500 }); + } return redirectWithErrorMessage( v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, @@ -132,20 +151,30 @@ export function UpsertScheduleForm({ possibleTimezones, showGenerateField, defaultTaskIdentifier, + onCancel, + submitFetcher, }: EditableScheduleElements & { showGenerateField: boolean; - /** - * Pre-fills the Task select when creating a new schedule (no `schedule` - * passed). Ignored when editing. - */ + /** Pre-fills the Task field on new schedules. Ignored when editing. */ defaultTaskIdentifier?: string; + /** When set, Cancel calls back instead of navigating. */ + onCancel?: () => void; + /** Submits via this fetcher with `_format=json` so the host can toast/close itself. */ + submitFetcher?: FetcherWithComponents; }) { - const lastSubmission = useActionData(); + const actionData = useActionData(); + // Only feed conform-shaped data (`intent`) to `useForm` — `{ ok, message }` + // envelopes lack `payload` and crash conform. + const fetcherSubmission = + submitFetcher?.data && typeof submitFetcher.data === "object" && "intent" in submitFetcher.data + ? submitFetcher.data + : undefined; + const lastSubmission = submitFetcher ? fetcherSubmission : actionData; const [selectedTimezone, setSelectedTimezone] = useState(schedule?.timezone ?? "UTC"); const isUtc = selectedTimezone === "UTC"; const [cronPattern, setCronPattern] = useState(schedule?.cron ?? ""); const navigation = useNavigation(); - const isLoading = navigation.state !== "idle"; + const isLoading = submitFetcher ? submitFetcher.state !== "idle" : navigation.state !== "idle"; const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -197,13 +226,14 @@ export function UpsertScheduleForm({ } const mode = schedule ? "edit" : "new"; + const FormComponent = submitFetcher?.Form ?? Form; return ( -
@@ -216,36 +246,41 @@ export function UpsertScheduleForm({
+ {submitFetcher ? : null} {schedule && }
- {!schedule && defaultTaskIdentifier ? ( - - ) : ( - - - - {taskIdentifier.error} - - )} + {(() => { + // Lock the task via hidden input when it's implied (sheet on a task page, or editing). + const lockedTaskIdentifier = schedule?.taskIdentifier ?? defaultTaskIdentifier; + return lockedTaskIdentifier ? ( + + ) : ( + + + + {taskIdentifier.error} + + ); + })()} {showGenerateField && }
-
+
- - Cancel - + {onCancel ? ( + + ) : ( + + Cancel + + )}
- + ); } From 109948105127e83ca1938390cf6d5ca3c582ab76 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 16 Jun 2026 13:55:36 +0100 Subject: [PATCH 3/8] fix(webapp): address Devin review findings on schedules sheet - Schedule detail action: honor `_format=json` in the project-not-found guard so fetcher callers get a structured error envelope. - ScheduleSheet: treat schedule data as loading when its friendlyId doesn't match the currently open id (fixes stale-data flash when switching schedules). - ScheduleSheet: render an explicit "schedule no longer exists" panel when the loader returns `null`, instead of an infinite spinner. --- .../route.tsx | 6 +++- .../route.tsx | 33 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx index 97214472a1..2407d308fe 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx @@ -85,6 +85,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }); if (!project) { + const message = `No project found with slug ${projectParam}`; + if (wantsJson) { + return json({ ok: false as const, message }, { status: 404 }); + } return redirectWithErrorMessage( v3SchedulePath( { slug: organizationSlug }, @@ -93,7 +97,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { { friendlyId: scheduleParam } ), request, - `No project found with slug ${projectParam}` + message ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 19222a89c7..061bcdf6e9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -638,8 +638,18 @@ function ScheduleSheet({ }, [deleteFetcher.state, deleteFetcher.data, toast, revalidator, onClose]); const schedule = detailFetcher.data?.schedule; + // Treat stale data (previous schedule still in fetcher cache after the + // user clicked a different row) as loading — otherwise we briefly flash + // the previous schedule's content while the new fetch is in flight. + const isStaleSchedule = !!schedule && !!openScheduleId && schedule.friendlyId !== openScheduleId; const isDetailLoading = - detailFetcher.state === "loading" || (!!openScheduleId && !schedule); + detailFetcher.state === "loading" || + isStaleSchedule || + (!!openScheduleId && schedule === undefined); + // Distinct from loading: the loader has resolved and the schedule is + // genuinely gone (returned `null`, e.g. deleted externally). + const isScheduleMissing = + !!openScheduleId && !isDetailLoading && detailFetcher.data?.schedule === null; const editData = editFetcher.data; const isEditLoading = mode === "edit" && (editFetcher.state === "loading" || !editData); @@ -665,9 +675,11 @@ function ScheduleSheet({ submitFetcher={updateFetcher} /> ) - ) : isDetailLoading || !schedule ? ( + ) : isDetailLoading ? ( - ) : ( + ) : isScheduleMissing ? ( + + ) : schedule ? ( + ) : ( + )} @@ -1098,3 +1112,16 @@ function TableLoading() {
); } + +function ScheduleMissingPanel({ onClose }: { onClose: () => void }) { + return ( +
+ + This schedule no longer exists. + + +
+ ); +} From 6675e0bbf33b3114fb9771f08bcc6d791f82d7c8 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 16 Jun 2026 14:04:45 +0100 Subject: [PATCH 4/8] chore(webapp): address CodeRabbit review nits - Remove unused `cond` import from `effect/STM` in `schedules.new` route. - Drop the leading slash from `docsPath("ai-chat/sessions")` in two spots so the generated URL doesn't double up on `/`. --- apps/webapp/app/components/BlankStatePanels.tsx | 2 +- .../route.tsx | 2 +- .../route.tsx | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index d36e947286..c52991d543 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -198,7 +198,7 @@ export function SessionsNone() { panelClassName="max-w-full" accessory={ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx index 3d2bbea6d9..2f05fb2e83 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx @@ -84,7 +84,7 @@ export default function Page() { Sessions docs diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index 276b6c8133..5dff802f1b 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -58,7 +58,6 @@ import { AIGeneratedCronField } from "../resources.orgs.$organizationSlug.projec import { TimezoneList } from "~/components/scheduled/timezones"; import { logger } from "~/services/logger.server"; import { Spinner } from "~/components/primitives/Spinner"; -import { cond } from "effect/STM"; import { useEnvironment } from "~/hooks/useEnvironment"; const cronFormat = `* * * * * From f60a6aa188eb1d3237307f138dcde1c3fbec6e6e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 16 Jun 2026 14:09:20 +0100 Subject: [PATCH 5/8] fix(webapp): treat edit fetcher's prior-schedule data as stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the detail-fetcher staleness check: when the user opens schedule B's edit form after editing schedule A, the prior `editFetcher.data` no longer matches the open schedule's friendlyId — show the spinner instead of briefly flashing A's data. --- .../route.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 061bcdf6e9..a776acf4ce 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -651,8 +651,13 @@ function ScheduleSheet({ const isScheduleMissing = !!openScheduleId && !isDetailLoading && detailFetcher.data?.schedule === null; const editData = editFetcher.data; + // Mirror the detail-fetcher staleness check so the edit form doesn't + // briefly flash a previously-edited schedule's data on the first render + // after switching schedules. + const isStaleEditData = + !!editData?.schedule && !!openScheduleId && editData.schedule.friendlyId !== openScheduleId; const isEditLoading = - mode === "edit" && (editFetcher.state === "loading" || !editData); + mode === "edit" && (editFetcher.state === "loading" || !editData || isStaleEditData); return ( !open && onClose()}> From 35cab79fd07ce6d3487fb7a560abf822ca833a53 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 16 Jun 2026 14:45:01 +0100 Subject: [PATCH 6/8] fix(webapp): toast on enable/disable failure in the schedules sheet The activeToggleFetcher effect previously short-circuited on `!data?.ok` and never surfaced server-returned errors (the update and delete effects already handled this). Split into success/failure branches so an `{ ok: false, message }` envelope from the action triggers `toast.error` instead of silently re-enabling the button. --- .../route.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index a776acf4ce..e60d827e9b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -565,7 +565,7 @@ function ScheduleSheet({ const detailFetcher = useTypedFetcher(); const editFetcher = useTypedFetcher(); // Embedded enable/disable — stays in the sheet via `_format=json`. - const activeToggleFetcher = useFetcher<{ ok: boolean; active?: boolean }>(); + const activeToggleFetcher = useFetcher<{ ok: boolean; active?: boolean; message?: string }>(); // Embedded update submission — same idea. const updateFetcher = useFetcher<{ ok: boolean; message?: string }>(); // Embedded delete submission — same idea. @@ -598,14 +598,18 @@ function ScheduleSheet({ if (mode === "edit" && editPath) editFetcher.load(editPath); }, [mode, editPath]); - // Reload inspector data so Enable/Disable label flips. + // Reload inspector data so Enable/Disable label flips; toast on error. useEffect(() => { const data = activeToggleFetcher.data; - if (activeToggleFetcher.state !== "idle" || !data?.ok || !detailPath) return; + if (activeToggleFetcher.state !== "idle" || !data) return; if (handledToggleRef.current === data) return; handledToggleRef.current = data; - detailFetcher.load(detailPath); - }, [activeToggleFetcher.state, activeToggleFetcher.data, detailPath]); + if (data.ok) { + if (detailPath) detailFetcher.load(detailPath); + } else if (data.message) { + toast.error(data.message); + } + }, [activeToggleFetcher.state, activeToggleFetcher.data, detailPath, toast]); // Toast + back to inspect + reload so the inspector reflects the update. useEffect(() => { From 0fdc50786083c55405d49a11e291fdb89f9a0d57 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 16 Jun 2026 15:01:44 +0100 Subject: [PATCH 7/8] fix(webapp): revalidate route loader after enable/disable + update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toggle and update effects reloaded `detailFetcher` (refreshing the inspector inside the sheet) but didn't call `revalidator.revalidate()`, so the page-level loader's `scheduleList` stayed stale — the sidebar's Status/CRON/Next-run properties and the schedules mini-table didn't reflect the change. Brought both effects in line with the create and delete flows. --- .../route.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index e60d827e9b..14f622c51a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -598,7 +598,8 @@ function ScheduleSheet({ if (mode === "edit" && editPath) editFetcher.load(editPath); }, [mode, editPath]); - // Reload inspector data so Enable/Disable label flips; toast on error. + // Reload inspector data so Enable/Disable label flips; revalidate the + // route loader so the sidebar's list/Overview stay in sync; toast on error. useEffect(() => { const data = activeToggleFetcher.data; if (activeToggleFetcher.state !== "idle" || !data) return; @@ -606,12 +607,14 @@ function ScheduleSheet({ handledToggleRef.current = data; if (data.ok) { if (detailPath) detailFetcher.load(detailPath); + revalidator.revalidate(); } else if (data.message) { toast.error(data.message); } - }, [activeToggleFetcher.state, activeToggleFetcher.data, detailPath, toast]); + }, [activeToggleFetcher.state, activeToggleFetcher.data, detailPath, toast, revalidator]); - // Toast + back to inspect + reload so the inspector reflects the update. + // Toast + back to inspect + reload + revalidate so both the inspector + // and the sidebar reflect the update. useEffect(() => { const data = updateFetcher.data; if (updateFetcher.state !== "idle" || !data) return; @@ -621,10 +624,11 @@ function ScheduleSheet({ toast.success(data.message ?? "Schedule updated"); setMode("inspect"); if (detailPath) detailFetcher.load(detailPath); + revalidator.revalidate(); } else if (data.message) { toast.error(data.message); } - }, [updateFetcher.state, updateFetcher.data, detailPath, toast]); + }, [updateFetcher.state, updateFetcher.data, detailPath, toast, revalidator]); // Toast + close + revalidate so the deleted row disappears. useEffect(() => { From 561c61da65918a74ff8c58e89e2b5aeaf2dd5b8e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 16 Jun 2026 15:20:56 +0100 Subject: [PATCH 8/8] fix(webapp): suppress sheet spinner on bg reload + uniquify form id - UpsertScheduleForm: derive a per-schedule `useForm` id (`edit-schedule-{friendlyId}` vs `create-schedule`) so both sheets can coexist in the DOM without clashing `htmlFor` targets or conform's internal error routing. - ScheduleSheet: drop `detailFetcher.state === "loading"` from `isDetailLoading`. The stale-schedule and no-data-yet checks already cover the cases where we genuinely lack good data; a background reload (e.g. after enable/disable) now keeps the inspector visible with its current values until the fresh data arrives, instead of flashing a spinner. --- .../route.tsx | 7 ++++--- .../route.tsx | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 14f622c51a..d82ebea7a5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -650,10 +650,11 @@ function ScheduleSheet({ // user clicked a different row) as loading — otherwise we briefly flash // the previous schedule's content while the new fetch is in flight. const isStaleSchedule = !!schedule && !!openScheduleId && schedule.friendlyId !== openScheduleId; + // Only show the loading spinner when we actually lack good data — + // background reloads (e.g. after enable/disable) keep the inspector + // visible with its current values until the fresh data arrives. const isDetailLoading = - detailFetcher.state === "loading" || - isStaleSchedule || - (!!openScheduleId && schedule === undefined); + isStaleSchedule || (!!openScheduleId && detailFetcher.data === undefined); // Distinct from loading: the loader has resolved and the schedule is // genuinely gone (returned `null`, e.g. deleted externally). const isScheduleMissing = diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index 5dff802f1b..1f7a2add41 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -181,7 +181,9 @@ export function UpsertScheduleForm({ const [form, { taskIdentifier, cron, timezone, externalId, environments, deduplicationKey }] = useForm({ - id: "create-schedule", + // Disambiguate per-schedule so both sheets (create + edit) can + // coexist without duplicate DOM ids breaking `htmlFor` / conform. + id: schedule?.friendlyId ? `edit-schedule-${schedule.friendlyId}` : "create-schedule", // TODO: type this lastSubmission: lastSubmission as any, shouldRevalidate: "onSubmit",