@@ -1463,9 +1471,15 @@ function queueAdjustedNs(timeNs: number, queuedDurationNs: number | undefined) {
function NodeText({ node }: { node: TraceEvent }) {
const className = "truncate";
+ // Only mark task-level spans as agent so the agents colour applies to
+ // the task row itself, not unrelated sub-spans (wait/log/etc.) that
+ // live underneath an agent run.
+ const isAgentTaskRow =
+ node.data.isAgentRun &&
+ (node.data.style?.icon === "task" || node.data.style?.icon === "task-cached");
return (
-
+
);
}
@@ -1882,21 +1896,12 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
onChange(text);
}, 250);
- const updateValue = useCallback((value: string) => {
- setValue(value);
- updateFilterText(value);
+ const updateValue = useCallback((next: string) => {
+ setValue(next);
+ updateFilterText(next);
}, []);
- return (
-
updateValue(e.target.value)}
- />
- );
+ return
;
}
function useAdjacentRunPaths({
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx
index 25cccaede8e..78c60904a6b 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx
@@ -9,8 +9,10 @@ import {
useTypedLoaderData,
} from "remix-typedjson";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
+import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { DevDisconnectedBanner, useDevPresence } from "~/components/DevPresence";
+import { InlineCode } from "~/components/code/InlineCode";
import { StepContentContainer } from "~/components/StepContentContainer";
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
@@ -32,6 +34,7 @@ import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { Spinner } from "~/components/primitives/Spinner";
import { StepNumber } from "~/components/primitives/StepNumber";
import { TextLink } from "~/components/primitives/TextLink";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { RunsFilters, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
@@ -72,7 +75,7 @@ export { shouldRevalidateRunsList as shouldRevalidate };
export const meta: MetaFunction = () => {
return [
{
- title: `Runs | Trigger.dev`,
+ title: `Runs metrics | Trigger.dev`,
},
];
};
@@ -136,7 +139,7 @@ export default function Page() {
return (
<>
-
+ } />
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
)}
@@ -450,7 +453,7 @@ function RunTaskInstructions({ task }: { task?: { slug: string } }) {
}
variant="secondary/medium"
LeadingIcon={BeakerIcon}
- leadingIconClassName="text-lime-500"
+ leadingIconClassName="text-tests"
className="inline-flex"
>
Test
@@ -479,3 +482,50 @@ function RunTaskInstructions({ task }: { task?: { slug: string } }) {
);
}
+
+function RunsHelpTooltip() {
+ return (
+
+ }
+ side="bottom"
+ className="max-w-sm p-3"
+ disableHoverableContent
+ content={
+
+
+
What is a run?
+
+ A run is a single instance of a task being executed. It's created when you trigger a
+ task, for example{" "}
+
+ yourTask.trigger({`{ foo: "bar" }`})
+
+ . Runs are durable, so they survive crashes, deploys, and restarts, and will
+ automatically retry on failure.
+
+
+
+
+
+ task.trigger()
+
+
+ Triggered from your backend code, an API call, or another task. Each call creates a
+ single run with the payload you pass in.
+
+
+
+
Scheduled triggers
+
+ Runs created automatically from a cron schedule attached to a scheduled task. Use
+ them for recurring jobs like nightly syncs or hourly cleanups.
+
+
+
+
+ }
+ />
+ );
+}
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 a837274222b..290ea35db8d 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
@@ -1,44 +1,11 @@
import { parse } from "@conform-to/zod";
-import {
- BoltIcon,
- BoltSlashIcon,
- BookOpenIcon,
- PencilSquareIcon,
- TrashIcon,
-} from "@heroicons/react/20/solid";
-import { DialogDescription } from "@radix-ui/react-dialog";
-import { Form, useLocation } from "@remix-run/react";
+import { useLocation } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
-import { InlineCode } from "~/components/code/InlineCode";
-import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
-import { Button, LinkButton } from "~/components/primitives/Buttons";
-import { DateTime } from "~/components/primitives/DateTime";
-import {
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTrigger,
-} from "~/components/primitives/Dialog";
-import { Header2, Header3 } from "~/components/primitives/Headers";
-import { InfoPanel } from "~/components/primitives/InfoPanel";
-import { Paragraph } from "~/components/primitives/Paragraph";
-import * as Property from "~/components/primitives/PropertyTable";
-import {
- Table,
- TableBlankRow,
- TableBody,
- TableCell,
- TableHeader,
- TableHeaderCell,
- TableRow,
-} from "~/components/primitives/Table";
-import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
-import { ScheduleTypeCombo } from "~/components/runs/v3/ScheduleType";
-import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
+import { LinkButton } from "~/components/primitives/Buttons";
+import { ScheduleInspector } from "~/components/schedules/ScheduleInspector";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -48,13 +15,7 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
import { requireUserId } from "~/services/session.server";
-import { cn } from "~/utils/cn";
-import {
- v3EditSchedulePath,
- v3ScheduleParams,
- v3SchedulePath,
- v3SchedulesPath,
-} from "~/utils/pathBuilder";
+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";
@@ -144,7 +105,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
friendlyId: scheduleParam,
});
return redirectWithSuccessMessage(
- v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
+ v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
`${scheduleParam} deleted`
);
@@ -202,14 +163,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
};
-function PlaceholderText({ title }: { title: string }) {
- return (
-
- );
-}
-
export default function Page() {
const { schedule } = useTypedLoaderData();
const location = useLocation();
@@ -217,234 +170,19 @@ export default function Page() {
const project = useProject();
const environment = useEnvironment();
- const isUtc = schedule.timezone === "UTC";
-
- const isImperative = schedule.type === "IMPERATIVE";
-
return (
-
-
- {schedule.friendlyId}
+
-
-
-
-
-
-
- Schedule ID
- {schedule.friendlyId}
-
-
- Task ID
- {schedule.taskIdentifier}
-
-
- Type
-
-
-
-
-
- CRON
-
-
-
{schedule.cron}
-
{schedule.cronDescription}
-
-
-
-
- Timezone
- {schedule.timezone}
-
-
- Environment
-
-
- {schedule.environments.map((env) => (
-
- ))}
-
-
-
- {isImperative && (
- <>
-
- External ID
-
- {schedule.externalId ? schedule.externalId : "–"}
-
-
-
- Deduplication key
-
- {schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–"}
-
-
-
- Status
-
-
-
-
- >
- )}
-
-
-
- Last 5 runs
-
-
-
-
Next 5 runs
-
-
-
- {!isUtc && {schedule.timezone}}
- UTC
-
-
-
- {schedule.active ? (
- schedule.nextRuns.length ? (
- schedule.nextRuns.map((run, index) => (
-
- {!isUtc && (
-
-
-
- )}
-
-
-
-
- ))
- ) : (
-
-
-
- )
- ) : (
-
-
-
- )}
-
-
-
- {!isImperative && (
-
-
- Schedules docs
-
- }
- panelClassName="max-w-full"
- >
- You can only edit a declarative schedule by updating your schedules.task and then
- running the CLI dev and deploy commands.
-
-
- )}
-
-
- {isImperative && (
-
-
-
-
-
-
-
- Edit schedule
-
-
-
- )}
-
+ }
+ />
);
}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route.tsx
index 38d330cbb0a..b01b9a7d93e 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route.tsx
@@ -2,7 +2,7 @@ import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server";
import { requireUserId } from "~/services/session.server";
-import { v3ScheduleParams, v3SchedulesPath } from "~/utils/pathBuilder";
+import { v3EnvironmentPath, v3ScheduleParams } from "~/utils/pathBuilder";
import { humanToCronSupported } from "~/v3/humanToCron.server";
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
@@ -21,7 +21,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
if (result.schedule?.type === "DECLARATIVE") {
throw redirect(
- v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam })
+ v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam })
);
}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx
deleted file mode 100644
index bcb10409442..00000000000
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx
+++ /dev/null
@@ -1,869 +0,0 @@
-import { useForm } from "@conform-to/react";
-import { parse } from "@conform-to/zod";
-import { ArrowUpCircleIcon, EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid";
-import { BookOpenIcon } from "@heroicons/react/24/solid";
-import { DialogClose } from "@radix-ui/react-dialog";
-import { type MetaFunction, Outlet, useFetcher, useLocation, useParams } from "@remix-run/react";
-import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { tryCatch } from "@trigger.dev/core/v3";
-import { useEffect, useState } from "react";
-import { typedjson, useTypedLoaderData } from "remix-typedjson";
-import { z } from "zod";
-import { SchedulesNoneAttached, SchedulesNoPossibleTaskPanel } from "~/components/BlankStatePanels";
-import { Feedback } from "~/components/Feedback";
-import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
-import { InlineCode } from "~/components/code/InlineCode";
-import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
-import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
-import { Button, LinkButton } from "~/components/primitives/Buttons";
-import { DateTime } from "~/components/primitives/DateTime";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTrigger,
-} from "~/components/primitives/Dialog";
-import { Fieldset } from "~/components/primitives/Fieldset";
-import { FormButtons } from "~/components/primitives/FormButtons";
-import { FormError } from "~/components/primitives/FormError";
-import { Header3 } from "~/components/primitives/Headers";
-import { InputGroup } from "~/components/primitives/InputGroup";
-import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
-import { Label } from "~/components/primitives/Label";
-import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
-import { PaginationControls } from "~/components/primitives/Pagination";
-import { Paragraph } from "~/components/primitives/Paragraph";
-import * as Property from "~/components/primitives/PropertyTable";
-import { SpinnerWhite } from "~/components/primitives/Spinner";
-import {
- RESIZABLE_PANEL_ANIMATION,
- ResizableHandle,
- ResizablePanel,
- ResizablePanelGroup,
- collapsibleHandleClassName,
-} from "~/components/primitives/Resizable";
-import {
- Table,
- TableBlankRow,
- TableBody,
- TableCell,
- TableHeader,
- TableHeaderCell,
- TableRow,
-} from "~/components/primitives/Table";
-import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
-import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
-import { ScheduleFilters, ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
-import {
- ScheduleTypeCombo,
- ScheduleTypeIcon,
- scheduleTypeName,
-} from "~/components/runs/v3/ScheduleType";
-import { useEnvironment } from "~/hooks/useEnvironment";
-import { useOrganization } from "~/hooks/useOrganizations";
-import { usePathName } from "~/hooks/usePathName";
-import { useProject } from "~/hooks/useProject";
-import { redirectWithErrorMessage } from "~/models/message.server";
-import { findProjectBySlug } from "~/models/project.server";
-import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
-import {
- type ScheduleListItem,
- ScheduleListPresenter,
-} from "~/presenters/v3/ScheduleListPresenter.server";
-import { requireUserId } from "~/services/session.server";
-import { cn } from "~/utils/cn";
-import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
-import {
- docsPath,
- EnvironmentParamSchema,
- v3BillingPath,
- v3NewSchedulePath,
- v3SchedulePath,
- v3SchedulesPath,
-} from "~/utils/pathBuilder";
-import { SetSchedulesAddOnService } from "~/v3/services/setSchedulesAddOn.server";
-import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
-
-export const meta: MetaFunction = () => {
- return [
- {
- title: `Schedules | Trigger.dev`,
- },
- ];
-};
-
-export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
- const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
-
- const project = await findProjectBySlug(organizationSlug, projectParam, userId);
- if (!project) {
- return redirectWithErrorMessage("/", request, "Project not found");
- }
-
- const environment = await findEnvironmentBySlug(project.id, envParam, userId);
- if (!environment) {
- return redirectWithErrorMessage("/", request, "Environment not found");
- }
-
- const url = new URL(request.url);
- const s = Object.fromEntries(url.searchParams.entries());
- const filters = ScheduleListFilters.parse(s);
-
- const presenter = new ScheduleListPresenter();
- const list = await presenter.call({
- userId,
- projectId: project.id,
- environmentId: environment.id,
- ...filters,
- });
-
- return typedjson(list);
-};
-
-const PurchaseSchema = z.discriminatedUnion("action", [
- z.object({
- action: z.literal("purchase"),
- amount: z.coerce
- .number()
- .int("Must be a whole number")
- .min(0, "Amount must be 0 or more"),
- }),
- z.object({
- action: z.literal("quota-increase"),
- amount: z.coerce
- .number()
- .int("Must be a whole number")
- .min(1, "Amount must be greater than 0"),
- }),
-]);
-
-export async function action({ request, params }: ActionFunctionArgs) {
- const userId = await requireUserId(request);
- const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
-
- const formData = await request.formData();
-
- const project = await findProjectBySlug(organizationSlug, projectParam, userId);
- const redirectPath = v3SchedulesPath(
- { slug: organizationSlug },
- { slug: projectParam },
- { slug: envParam }
- );
-
- if (!project) {
- throw redirectWithErrorMessage(redirectPath, request, "Project not found");
- }
-
- const submission = parse(formData, { schema: PurchaseSchema });
-
- if (!submission.value || submission.intent !== "submit") {
- return json(submission);
- }
-
- const service = new SetSchedulesAddOnService();
- const [error, result] = await tryCatch(
- service.call({
- userId,
- organizationId: project.organizationId,
- action: submission.value.action,
- amount: submission.value.amount,
- })
- );
-
- if (error) {
- submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
- return json(submission);
- }
-
- if (!result.success) {
- submission.error.amount = [result.error];
- return json(submission);
- }
-
- return json({ ok: true } as const);
-}
-
-export default function Page() {
- const {
- schedules,
- possibleTasks,
- hasFilters,
- limits,
- currentPage,
- totalPages,
- canPurchaseSchedules,
- extraSchedules,
- maxScheduleQuota,
- planScheduleLimit,
- schedulePricing,
- } = useTypedLoaderData();
- const location = useLocation();
- const organization = useOrganization();
- const project = useProject();
- const environment = useEnvironment();
- const pathName = usePathName();
-
- const plan = useCurrentPlan();
- const requiresUpgrade =
- plan?.v3Subscription?.plan &&
- limits.used >= plan.v3Subscription.plan.limits.schedules.number &&
- !plan.v3Subscription.plan.limits.schedules.canExceed;
- const canUpgrade =
- plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.schedules.canExceed;
-
- const { scheduleParam } = useParams();
- const isShowingNewPane = pathName.endsWith("/new");
- const isShowingSchedule = !!scheduleParam;
-
- return (
-
-
-
-
-
-
- {schedules.map((schedule) => (
-
- {schedule.friendlyId}
- {schedule.id}
-
- ))}
-
-
-
-
- Schedules docs
-
-
-
-
-
-
-
- {possibleTasks.length === 0 ? (
-
-
-
- ) : schedules.length === 0 && !hasFilters ? (
-
-
-
- ) : (
- <>
-
-
-
-
- {limits.used >= limits.limit ? (
-
- ) : (
-
- New schedule
-
- )}
-
-
-
-
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]"
- )}
- >
-
-
1 && "justify-end border-t border-grid-dimmed px-2 py-3"
- )}
- >
-
-
-
-
-
-
-
-
-
- }
- content={`${Math.round((limits.used / limits.limit) * 100)}%`}
- />
-
- {requiresUpgrade ? (
-
- You've used all {limits.limit} of your available schedules. Upgrade your
- plan to enable more.
-
- ) : (
-
-
- You've used {limits.used}/{limits.limit} of your schedules
-
-
-
- )}
-
- {canPurchaseSchedules && schedulePricing ? (
-
- ) : canUpgrade ? (
-
- Upgrade
-
- ) : (
-
Request more}
- defaultValue="help"
- />
- )}
-
-
-
- >
- )}
-
-
-
-
{}}
- collapsedSize="0px"
- collapseAnimation={RESIZABLE_PANEL_ANIMATION}
- >
-
-
-
-
-
-
-
- );
-}
-
-function SchedulesTable({
- schedules,
- hasFilters,
-}: {
- schedules: ScheduleListItem[];
- hasFilters: boolean;
-}) {
- const organization = useOrganization();
- const project = useProject();
- const environment = useEnvironment();
- const location = useLocation();
- const { scheduleParam } = useParams();
-
- return (
-
-
-
- ID
- Task ID
-
-
-
-
-
- {scheduleTypeName("DECLARATIVE")}
-
-
-
- Declarative schedules are defined in a{" "}
- schedules.task with the{" "}
- cron
- property. They sync when you update your{" "}
- schedules.task definition and run
- the CLI dev or deploy commands.
-
-
-
-
-
-
- {scheduleTypeName("IMPERATIVE")}
-
-
-
- Imperative schedules are defined here in the dashboard or by using the SDK
- functions to create or delete them. They can be created, updated, disabled, and
- deleted from the dashboard or using the SDK.
-
-
-
- View the docs
-
-
- }
- >
- Type
-
- External ID
- CRON
- CRON description
- Timezone
- Next run
- Last run
- Deduplication key
- Environments
- Enabled
-
-
-
- {schedules.length === 0 ? (
- There are no matches for your filters
- ) : (
- schedules.map((schedule) => {
- const path = `${v3SchedulePath(organization, project, environment, schedule)}${
- location.search
- }`;
- const isSelected = scheduleParam === schedule.friendlyId;
- const cellClass = schedule.active ? "" : "opacity-50";
- const selectedActionClass = isSelected ? "text-text-bright" : undefined;
- return (
-
-
- {schedule.friendlyId}
-
-
- {schedule.taskIdentifier}
-
-
-
-
-
- {schedule.type === "IMPERATIVE"
- ? schedule.externalId
- ? schedule.externalId
- : "–"
- : "N/A"}
-
-
- {schedule.cron}
-
-
- {schedule.cronDescription}
-
-
- {schedule.timezone}
-
-
-
-
-
- {schedule.lastRun ? (
-
- ) : (
- "–"
- )}
-
-
- {schedule.type === "IMPERATIVE"
- ? schedule.userProvidedDeduplicationKey
- ? schedule.deduplicationKey
- : "–"
- : "N/A"}
-
-
-
- {schedule.environments.map((env) => (
-
- ))}
-
-
-
- {schedule.type === "IMPERATIVE" ? (
-
- ) : (
- "N/A"
- )}
-
-
- );
- })
- )}
-
-
- );
-}
-
-function PurchaseSchedulesModal({
- schedulePricing,
- extraSchedules,
- usedSchedules,
- maxQuota,
- planScheduleLimit,
- triggerButton,
-}: {
- schedulePricing: {
- stepSize: number;
- centsPerStep: number;
- };
- extraSchedules: number;
- usedSchedules: number;
- maxQuota: number;
- planScheduleLimit: number;
- triggerButton?: React.ReactNode;
-}) {
- const fetcher = useFetcher();
- const lastSubmission =
- fetcher.data && typeof fetcher.data === "object" && "intent" in fetcher.data
- ? fetcher.data
- : undefined;
- const [form, { amount }] = useForm({
- id: "purchase-schedules",
- lastSubmission: lastSubmission as any,
- onValidate({ formData }) {
- return parse(formData, { schema: PurchaseSchema });
- },
- shouldRevalidate: "onSubmit",
- });
-
- const stepSize = schedulePricing.stepSize;
- const [bundles, setBundles] = useState(Math.round(extraSchedules / stepSize));
- useEffect(() => {
- setBundles(Math.round(extraSchedules / stepSize));
- }, [extraSchedules, stepSize]);
- const amountValue = bundles * stepSize;
- const isLoading = fetcher.state !== "idle";
-
- const [open, setOpen] = useState(false);
- useEffect(() => {
- const data = fetcher.data;
- if (
- fetcher.state === "idle" &&
- data !== null &&
- typeof data === "object" &&
- "ok" in data &&
- data.ok
- ) {
- setOpen(false);
- }
- }, [fetcher.state, fetcher.data]);
-
- const state = updateScheduleState({
- value: amountValue,
- existingValue: extraSchedules,
- quota: maxQuota,
- usedSchedules,
- planScheduleLimit,
- });
- const changeClassName =
- state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
-
- const pricePerSchedule = schedulePricing.centsPerStep / stepSize / 100;
- const pricePerStep = schedulePricing.centsPerStep / 100;
- const stepUnit = formatNumber(stepSize);
- const title = extraSchedules === 0 ? "Purchase extra schedules…" : "Add/remove extra schedules…";
-
- return (
-
- );
-}
-
-function updateScheduleState({
- value,
- existingValue,
- quota,
- usedSchedules,
- planScheduleLimit,
-}: {
- value: number;
- existingValue: number;
- quota: number;
- usedSchedules: number;
- planScheduleLimit: number;
-}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_delete" {
- if (value === existingValue) return "no_change";
- if (value < existingValue) {
- const newTotalLimit = planScheduleLimit + value;
- if (usedSchedules > newTotalLimit) {
- return "need_to_delete";
- }
- return "decrease";
- }
- if (value > quota) return "above_quota";
- return "increase";
-}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
index 688477281d6..d335dbaa08b 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
@@ -1,17 +1,27 @@
-import { ArrowsRightLeftIcon, BookOpenIcon, XCircleIcon } from "@heroicons/react/24/solid";
+import { BoltIcon, BoltSlashIcon } from "@heroicons/react/20/solid";
+import { BookOpenIcon } from "@heroicons/react/24/solid";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { Clipboard, ClipboardCheck } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import simplur from "simplur";
import { z } from "zod";
+import { AIChatIcon } from "~/assets/icons/AIChatIcon";
+import { MessageInputIcon } from "~/assets/icons/MessageInputIcon";
+import { MessageOutputIcon } from "~/assets/icons/MessageOutputIcon";
+import { MoveToBottomIcon } from "~/assets/icons/MoveToBottomIcon";
+import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
+import { TextInlineIcon } from "~/assets/icons/TextInlineIcon";
+import { TextWrapIcon } from "~/assets/icons/TextWrapIcon";
import { CodeBlock } from "~/components/code/CodeBlock";
import { PageBody } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
-import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
-import { Header2 } from "~/components/primitives/Headers";
+import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
-import SegmentedControl from "~/components/primitives/SegmentedControl";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import {
@@ -19,11 +29,17 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
+import { Spinner } from "~/components/primitives/Spinner";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
-import { SimpleTooltip } from "~/components/primitives/Tooltip";
+import {
+ SimpleTooltip,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "~/components/primitives/Tooltip";
import { AgentView } from "~/components/runs/v3/agent/AgentView";
-import { RealtimeStreamViewer } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
import { RunTag } from "~/components/runs/v3/RunTag";
import {
descriptionForTaskRunStatus,
@@ -41,9 +57,14 @@ import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { SessionPresenter } from "~/presenters/v3/SessionPresenter.server";
-import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
+import {
+ type StreamChunk,
+ useRealtimeStream,
+} from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
import { requireUserId } from "~/services/session.server";
+import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
import { cn } from "~/utils/cn";
+import { throwNotFound } from "~/utils/httpErrors";
import {
docsPath,
EnvironmentParamSchema,
@@ -51,7 +72,6 @@ import {
v3RunsPath,
v3SessionsPath,
} from "~/utils/pathBuilder";
-import { throwNotFound } from "~/utils/httpErrors";
const ParamsSchema = EnvironmentParamSchema.extend({
sessionParam: z.string(),
@@ -101,8 +121,8 @@ export default function Page() {
session.closedAt != null
? "CLOSED"
: session.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now()
- ? "EXPIRED"
- : "ACTIVE";
+ ? "EXPIRED"
+ : "ACTIVE";
const displayId = session.externalId ?? session.friendlyId;
const sessionsPath = v3SessionsPath(organization, project, environment);
@@ -113,35 +133,20 @@ export default function Page() {
+
+
+
+
}
/>
Sessions docs
- {status === "ACTIVE" && (
-
- )}
@@ -172,74 +177,524 @@ function ConversationPane({ session }: { session: LoadedSession }) {
const environment = useEnvironment();
const { value, replace } = useSearchParams();
const isRaw = value("raw") === "1";
- const stream: "out" | "in" = value("stream") === "in" ? "in" : "out";
const sessionId = session.agentView.sessionId;
const encodedSession = encodeURIComponent(sessionId);
const sessionResourceBase = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/sessions/${encodedSession}/realtime/v1`;
+ const setView = useCallback((raw: boolean) => replace({ raw: raw ? "1" : undefined }), [replace]);
+
return (
-
-
-
-
replace({ raw: v === "raw" ? "1" : undefined })}
- />
-
+
{isRaw ? (
-
-
- replace({ stream: undefined })}
- >
- Output
-
- replace({ stream: "in" })}
- >
- Input
-
-
- }
- />
-
+
) : (
-
+ );
+}
+
+function ConversationUtilityBar({
+ isRaw,
+ onChangeView,
+ right,
+}: {
+ isRaw: boolean;
+ onChangeView: (raw: boolean) => void;
+ right?: React.ReactNode;
+}) {
+ return (
+
+
+ onChangeView(false)}
+ >
+ Rendered
+
+ onChangeView(true)}
+ >
+ Raw
+
+
+ {right}
+
+ );
+}
+
+type MergedChunk = StreamChunk & { source: "in" | "out" };
+
+function formatInlineData(data: unknown): string {
+ if (typeof data === "string") return data;
+ const json = JSON.stringify(data);
+ return json ?? String(data);
+}
+
+function formatWrappedData(data: unknown): string {
+ if (typeof data === "string") return data;
+ const json = JSON.stringify(data, null, 2);
+ return json ?? String(data);
+}
+
+const ROW_NUMBER_COL_MIN_CH = 3;
+const TIME_COL_WIDTH = "7rem";
+const TYPE_COL_WIDTH = "5rem";
+
+function RawConversationView({
+ inResourcePath,
+ outResourcePath,
+ isRaw,
+ onChangeView,
+}: {
+ inResourcePath: string;
+ outResourcePath: string;
+ isRaw: boolean;
+ onChangeView: (raw: boolean) => void;
+}) {
+ const {
+ chunks: inChunks,
+ error: inError,
+ isConnected: inConnected,
+ } = useRealtimeStream(inResourcePath);
+ const {
+ chunks: outChunks,
+ error: outError,
+ isConnected: outConnected,
+ } = useRealtimeStream(outResourcePath);
+
+ const merged = useMemo
(() => {
+ const all: MergedChunk[] = [
+ ...inChunks.map((c) => ({ ...c, source: "in" as const })),
+ ...outChunks.map((c) => ({ ...c, source: "out" as const })),
+ ];
+ all.sort((a, b) => a.timestamp - b.timestamp);
+ return all;
+ }, [inChunks, outChunks]);
+
+ const longestInlineMessage = useMemo(() => {
+ let longest = "";
+ for (const chunk of merged) {
+ const text = formatInlineData(chunk.data);
+ if (text.length > longest.length) longest = text;
+ }
+ return longest;
+ }, [merged]);
+
+ const error = inError ?? outError;
+ const isConnected = inConnected || outConnected;
+ const totalChunks = merged.length;
+
+ const scrollRef = useRef(null);
+ const bottomRef = useRef(null);
+ const [isAtBottom, setIsAtBottom] = useState(true);
+ const [copied, setCopied] = useState(false);
+ const [mouseOver, setMouseOver] = useState(false);
+ const [isWrapped, setIsWrapped] = useState(false);
+
+ const getCompactText = useCallback(() => {
+ return merged
+ .map((chunk) => {
+ const prefix = chunk.source === "in" ? "» " : "« ";
+ return `${prefix}${formatInlineData(chunk.data)}`;
+ })
+ .join("\n");
+ }, [merged]);
+
+ const onCopied = useCallback(
+ (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ navigator.clipboard.writeText(getCompactText());
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ },
+ [getCompactText]
+ );
+
+ useEffect(() => {
+ const bottomElement = bottomRef.current;
+ const scrollElement = scrollRef.current;
+ if (!bottomElement || !scrollElement) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (entry) setIsAtBottom(entry.isIntersecting);
+ },
+ { root: scrollElement, threshold: 0.1, rootMargin: "0px" }
+ );
+
+ observer.observe(bottomElement);
+
+ let scrollTimeout: ReturnType | null = null;
+ const handleScroll = () => {
+ if (!scrollElement || !bottomElement) return;
+ if (scrollTimeout) clearTimeout(scrollTimeout);
+ scrollTimeout = setTimeout(() => {
+ const scrollBottom = scrollElement.scrollTop + scrollElement.clientHeight;
+ const isNearBottom = scrollElement.scrollHeight - scrollBottom < 50;
+ setIsAtBottom(isNearBottom);
+ }, 100);
+ };
+
+ scrollElement.addEventListener("scroll", handleScroll);
+ const scrollBottom = scrollElement.scrollTop + scrollElement.clientHeight;
+ const isNearBottom = scrollElement.scrollHeight - scrollBottom < 50;
+ setIsAtBottom(isNearBottom);
+
+ return () => {
+ observer.disconnect();
+ scrollElement.removeEventListener("scroll", handleScroll);
+ if (scrollTimeout) clearTimeout(scrollTimeout);
+ };
+ }, [merged.length]);
+
+ useEffect(() => {
+ if (isAtBottom && scrollRef.current) {
+ const currentScrollLeft = scrollRef.current.scrollLeft;
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ scrollRef.current.scrollLeft = currentScrollLeft;
+ }
+ }, [merged, isAtBottom]);
+
+ const rowVirtualizer = useVirtualizer({
+ count: merged.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 28,
+ overscan: 8,
+ });
+
+ const rowNumberWidthCh = Math.max(ROW_NUMBER_COL_MIN_CH, merged.length.toString().length);
+
+ const controls = (
+
+
+
+
+ {isConnected ? (
+
+ ) : (
+
+ )}
+
+
+ {isConnected ? "Connected" : "Disconnected"}
+
+
+
+
+ {simplur`${totalChunks} chunk[|s]`}
+
+
+
+ setMouseOver(true)}
+ onMouseLeave={() => setMouseOver(false)}
+ className={cn(
+ "transition-colors duration-100 focus-custom",
+ totalChunks === 0
+ ? "cursor-not-allowed opacity-50"
+ : copied
+ ? "text-success hover:cursor-pointer"
+ : "text-text-dimmed hover:cursor-pointer hover:text-text-bright"
+ )}
+ >
+ {copied ? : }
+
+
+ {copied ? "Copied" : "Copy"}
+
+
+
+
+
+ setIsWrapped((w) => !w)}
+ className="text-text-dimmed transition-colors focus-custom hover:cursor-pointer hover:text-text-bright"
+ >
+ {isWrapped ? (
+
+ ) : (
+
+ )}
+
+
+ {isWrapped ? "Show messages on one line" : "Wrap messages"}
+
+
+
+
+
+ {
+ if (isAtBottom) {
+ scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
+ } else {
+ bottomRef.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "end",
+ inline: "nearest",
+ });
+ }
+ }}
+ className={cn(
+ "text-text-dimmed transition-colors focus-custom",
+ totalChunks === 0
+ ? "cursor-not-allowed opacity-50"
+ : "hover:cursor-pointer hover:text-text-bright"
+ )}
+ >
+ {isAtBottom ? (
+
+ ) : (
+
+ )}
+
+
+ {isAtBottom ? "Scroll to top" : "Scroll to bottom"}
+
+
+
+
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+ {error && (
+
+
+ Error: {error.message}
+
+
+ )}
+
+ {merged.length === 0 && !error && (
+
+ {isConnected ? (
+
+
+
+ Waiting for data…
+
+
+ ) : (
+
+ No data received
+
+ )}
+
+ )}
+
+ {merged.length > 0 && (
+ <>
+ {!isWrapped && longestInlineMessage && (
+
+
+
+
+
{longestInlineMessage}
+
+ )}
+
+ {rowVirtualizer.getVirtualItems().map((virtualItem) => {
+ const chunk = merged[virtualItem.index];
+ return (
+
rowVirtualizer.measureElement(el)}
+ index={virtualItem.index}
+ />
+ );
+ })}
+
+
+ >
+ )}
+
+
+ >
+ );
+}
+
+function StreamColumnHeader({
+ rowNumberWidthCh,
+ timeColWidth,
+ typeColWidth,
+ className,
+}: {
+ rowNumberWidthCh: number;
+ timeColWidth: string;
+ typeColWidth: string;
+ className?: string;
+}) {
+ return (
+
+
+
+ Time
+
+
+ Type
+
+
Message
);
}
-function InspectorPane({
- session,
- status,
+function MergedStreamRow({
+ chunk,
+ lineNumber,
+ rowNumberWidthCh,
+ timeColWidth,
+ typeColWidth,
+ isWrapped,
+ start,
+ measure,
+ index,
}: {
- session: LoadedSession;
- status: SessionStatus;
+ chunk: MergedChunk;
+ lineNumber: number;
+ rowNumberWidthCh: number;
+ timeColWidth: string;
+ typeColWidth: string;
+ isWrapped: boolean;
+ start: number;
+ measure: (el: HTMLDivElement | null) => void;
+ index: number;
}) {
+ const wrappedData = formatWrappedData(chunk.data);
+ const inlineData = formatInlineData(chunk.data);
+
+ const date = new Date(chunk.timestamp);
+ const timeString = date.toLocaleTimeString("en-US", {
+ hour12: false,
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ const milliseconds = date.getMilliseconds().toString().padStart(3, "0");
+ const timestamp = `${timeString}.${milliseconds}`;
+
+ const isInput = chunk.source === "in";
+
+ return (
+
+
+ {lineNumber}
+
+
+ {timestamp}
+
+
+
+ {isInput ? (
+
+ ) : (
+
+ )}
+ {isInput ? "Input" : "Output"}
+
+
+
+ {isWrapped ? wrappedData : inlineData}
+
+
+ );
+}
+
+function InspectorPane({ session, status }: { session: LoadedSession; status: SessionStatus }) {
const { value, replace } = useSearchParams();
const tab = value("tab") ?? "overview";
const organization = useOrganization();
@@ -255,10 +710,7 @@ function InspectorPane({
-
-
- {session.friendlyId}
-
+ {session.friendlyId}
@@ -293,7 +745,7 @@ function InspectorPane({
{tab === "overview" ? (
) : tab === "runs" ? (
-
+
) : (
)}
@@ -302,71 +754,82 @@ function InspectorPane({
);
}
-function OverviewTab({
- session,
- status,
-}: {
- session: LoadedSession;
- status: SessionStatus;
-}) {
+function OverviewTab({ session, status }: { session: LoadedSession; status: SessionStatus }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const isAdmin = useHasAdminAccess();
+ const sessionsPath = v3SessionsPath(organization, project, environment);
return (
-
- Status
-
-
-
-
+
+
+ Status
+
+
+
+
+ {status === "ACTIVE" && (
+
+ )}
+
Friendly ID
-
+
{session.externalId ? (
External ID
-
+
) : null}
Type
- {session.type}
+ {session.type}
- Task
+ Agent ID
- {session.taskIdentifier}
+ {session.taskIdentifier}
{session.currentRun ? (
Current run
-
-
- {session.currentRun.friendlyId}
- }
- content={descriptionForTaskRunStatus(session.currentRun.status)}
- disableHoverableContent
- />
-
-
+
+
+ {session.currentRun.friendlyId}
+
+ }
+ content={descriptionForTaskRunStatus(session.currentRun.status)}
+ disableHoverableContent
+ buttonClassName="w-fit"
+ />
+
) : null}
@@ -446,9 +909,7 @@ function OverviewTab({
Stream basin
-
- {session.streamBasinName ?? "(global)"}
-
+ {session.streamBasinName ?? "(global)"}
@@ -460,21 +921,19 @@ function OverviewTab({
function MetadataTab({ session }: { session: LoadedSession }) {
if (session.metadata == null) {
- return (
-
No metadata.
- );
+ return
No metadata.;
}
const json = JSON.stringify(session.metadata, null, 2);
- return (
-
- );
+ return
;
}
function RunsTab({
session,
+ status,
allRunsPath,
}: {
session: LoadedSession;
+ status: SessionStatus;
allRunsPath: string;
}) {
const organization = useOrganization();
@@ -486,57 +945,95 @@ function RunsTab({
}
return (
-
-
- {session.runs.map((entry) => {
- const runPath = entry.run
- ? v3RunPath(organization, project, environment, {
- friendlyId: entry.run.friendlyId,
- })
- : undefined;
- return (
-
-
-
- {entry.reason}
-
-
-
-
-
-
- {entry.run && runPath ? (
-
-
-
-
- }
- content={`Jump to run`}
- disableHoverableContent
- />
- ) : (
- –
- )}
-
-
- );
- })}
-
-
-
+
+
+
+ Session:
+
+
+ {sessionStatusBlurb(status)}
+
View all runs
-
+
+ {session.runs.map((entry, idx) => {
+ const isLast = idx === session.runs.length - 1;
+ const runPath = entry.run
+ ? v3RunPath(organization, project, environment, {
+ friendlyId: entry.run.friendlyId,
+ })
+ : undefined;
+ return (
+
+
+ {entry.reason} run
+
+
+
+
+ {entry.run && runPath ? (
+ <>
+
+
+
+ }
+ content="Jump to run"
+ disableHoverableContent
+ />
+
+ >
+ ) : (
+ –
+ )}
+
+ );
+ })}
);
}
+function sessionStatusBlurb(status: SessionStatus): string {
+ switch (status) {
+ case "ACTIVE":
+ return "Accepting new runs";
+ case "CLOSED":
+ return "No longer accepting new runs";
+ case "EXPIRED":
+ return "Expired without being closed";
+ }
+}
+
+function TimelineRow({
+ children,
+ isLast,
+ lineVariant = "solid",
+}: {
+ children: React.ReactNode;
+ isLast?: boolean;
+ lineVariant?: "solid" | "dashed";
+}) {
+ return (
+
+
+
+ {!isLast &&
+ (lineVariant === "dashed" ? (
+
+ ) : (
+
+ ))}
+
+
+ {children}
+
+
+ );
+}
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 510cb880468..1d193d2979f 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
@@ -2,11 +2,15 @@ import { BookOpenIcon } from "@heroicons/react/24/solid";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
+import { InlineCode } from "~/components/code/InlineCode";
import { ListPagination } from "~/components/ListPagination";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { SessionFilters } from "~/components/sessions/v1/SessionFilters";
import { SessionsTable } from "~/components/sessions/v1/SessionsTable";
import { SessionsNone } from "~/components/BlankStatePanels";
@@ -74,13 +78,13 @@ export default function Page() {
return (
<>
-
+ } />
Sessions docs
@@ -94,7 +98,7 @@ export default function Page() {
) : (
-
+
@@ -110,3 +114,50 @@ export default function Page() {
>
);
}
+
+function SessionsHelpTooltip() {
+ return (
+
+ }
+ side="bottom"
+ className="max-w-sm p-3"
+ disableHoverableContent
+ content={
+
+
+
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.
+
+
+
+
+
+ chat.agent
+
+
+ The high-level chat building block. Built on sessions and handles the chat turn loop
+ for you. Use it for chat apps and conversational AI experiences.
+
+
+
+
+ sessions.start()
+
+
+ The raw sessions API. Use it for non-chat patterns like agent inboxes, approval
+ flows, or server-to-server streaming where you need a durable bi-directional
+ channel.
+
+
+
+
+ }
+ />
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.dashboard/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.dashboard/route.tsx
new file mode 100644
index 00000000000..9389c15aa12
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.dashboard/route.tsx
@@ -0,0 +1,341 @@
+import { BookOpenIcon } from "@heroicons/react/20/solid";
+import { type MetaFunction } from "@remix-run/react";
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { Suspense, useMemo } from "react";
+import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
+import { PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { LinkButton } from "~/components/primitives/Buttons";
+import { Card } from "~/components/primitives/charts/Card";
+import type { ChartConfig } from "~/components/primitives/charts/Chart";
+import { Chart } from "~/components/primitives/charts/ChartCompound";
+import { Header1, Header3 } from "~/components/primitives/Headers";
+import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { Spinner } from "~/components/primitives/Spinner";
+import { useEnvironment } from "~/hooks/useEnvironment";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import {
+ tasksDashboardPresenter,
+ type DailyRunPoint,
+} from "~/presenters/v3/TasksDashboardPresenter.server";
+import { requireUserId } from "~/services/session.server";
+import {
+ docsPath,
+ EnvironmentParamSchema,
+ v3EnvironmentPath,
+ v3RunsPath,
+} from "~/utils/pathBuilder";
+
+export const meta: MetaFunction = () => {
+ return [{ title: "Tasks | Trigger.dev" }];
+};
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ throw new Response(undefined, { status: 404, statusText: "Project not found" });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ throw new Response(undefined, { status: 404, statusText: "Environment not found" });
+ }
+
+ const result = await tasksDashboardPresenter.call({
+ organizationId: project.organizationId,
+ environmentId: environment.id,
+ environmentType: environment.type,
+ });
+
+ return typeddefer(result);
+};
+
+const isoDateFormatter = new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ day: "numeric",
+ timeZone: "utc",
+});
+
+function formatDay(value: string) {
+ // value is a YYYY-MM-DD date string
+ const d = new Date(value + "T00:00:00Z");
+ return isoDateFormatter.format(d);
+}
+
+const STANDARD_EXAMPLE = `import { task } from "@trigger.dev/sdk";
+
+export const helloWorld = task({
+ id: "hello-world",
+ run: async (payload: { name: string }) => {
+ return { greeting: \`Hello, \${payload.name}!\` };
+ },
+});
+`;
+
+const SCHEDULED_EXAMPLE = `import { schedules } from "@trigger.dev/sdk";
+
+export const dailyReport = schedules.task({
+ id: "daily-report",
+ cron: "0 9 * * *",
+ run: async (payload) => {
+ // Runs every day at 9am UTC
+ return { ranAt: payload.timestamp };
+ },
+});
+`;
+
+const AGENT_EXAMPLE = `import { agent } from "@trigger.dev/sdk/ai";
+
+export const supportAgent = agent({
+ id: "support-agent",
+ model: "anthropic/claude-sonnet-4-6",
+ instructions: "You are a helpful customer support agent.",
+});
+`;
+
+export default function TasksDashboardPage() {
+ const { counts, series } = useTypedLoaderData
();
+ const organization = useOrganization();
+ const project = useProject();
+ const environment = useEnvironment();
+
+ return (
+
+
+
+
+
+
+
Tasks overview
+
+ }>
+
+ {(s) => (
+
+ )}
+
+
+ }>
+
+ {(s) => (
+
+ )}
+
+
+ }>
+
+ {(s) => (
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+function PanelSkeleton({ title }: { title: string }) {
+ return (
+
+ {title}
+
+
+
+
+ );
+}
+
+type TaskTypePanelProps = {
+ title: string;
+ count: number;
+ description: string;
+ example: string;
+ data: DailyRunPoint[];
+ seriesColor: string;
+ listingPath: string;
+ docsHref: string;
+ emptyTitle: string;
+ emptyDescription: string;
+ emptyCta: string;
+ exampleCode: string;
+};
+
+function TaskTypePanel(props: TaskTypePanelProps) {
+ const {
+ title,
+ count,
+ description,
+ example,
+ data,
+ seriesColor,
+ listingPath,
+ docsHref,
+ emptyTitle,
+ emptyDescription,
+ emptyCta,
+ exampleCode,
+ } = props;
+
+ const hasData = count > 0;
+
+ const chartConfig = useMemo(
+ () => ({
+ count: {
+ label: "Runs",
+ color: seriesColor,
+ },
+ }),
+ [seriesColor]
+ );
+
+ return (
+
+
+ {title}
+
+
+ {count.toLocaleString()}
+
+
+
+
+
+ formatDay(value as string)}
+ fillContainer
+ >
+ formatDay(label)}
+ xAxisProps={{ tickFormatter: (value) => formatDay(value) }}
+ />
+
+
+ {hasData ? (
+ <>
+
+ {description}
+
+
+
+ View all
+
+
+ Read docs
+
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function EmptyContent({
+ title,
+ description,
+ example,
+ listingPath,
+ docsHref,
+ cta,
+ exampleCode,
+}: {
+ title: string;
+ description: string;
+ example: string;
+ listingPath: string;
+ docsHref: string;
+ cta: string;
+ exampleCode: string;
+}) {
+ const copyExample = () => {
+ if (typeof navigator !== "undefined" && navigator.clipboard) {
+ navigator.clipboard.writeText(exampleCode).catch(() => {
+ /* swallow */
+ });
+ }
+ };
+
+ return (
+ <>
+ {title}
+
+ {description}
+
+
+
+ {cta}
+
+
+
+ Read docs
+
+
+ >
+ );
+}
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
new file mode 100644
index 00000000000..0154c536ac4
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx
@@ -0,0 +1,850 @@
+import { type MetaFunction } from "@remix-run/react";
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
+import { TypedAwait, typeddefer, useTypedFetcher, useTypedLoaderData } from "remix-typedjson";
+import { z } from "zod";
+import { PlusIcon } from "@heroicons/react/20/solid";
+import { BeakerIcon } from "~/assets/icons/BeakerIcon";
+import { ClockIcon } from "~/assets/icons/ClockIcon";
+import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
+import { RunsIcon } from "~/assets/icons/RunsIcon";
+import { PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { DirectionSchema, ListPagination } from "~/components/ListPagination";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
+import { Card } from "~/components/primitives/charts/Card";
+import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTrigger,
+} from "~/components/primitives/Dialog";
+import { PurchaseSchedulesModal } from "~/components/schedules/PurchaseSchedulesModal";
+import { SchedulesUsageBar } from "~/components/schedules/SchedulesUsageBar";
+import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
+import { CopyableText } from "~/components/primitives/CopyableText";
+import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime";
+import { Header2, Header3 } from "~/components/primitives/Headers";
+import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import * as Property from "~/components/primitives/PropertyTable";
+import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
+import { ScheduleInspector } from "~/components/schedules/ScheduleInspector";
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from "~/components/primitives/Resizable";
+import { Spinner } from "~/components/primitives/Spinner";
+import {
+ Table,
+ TableBlankRow,
+ TableBody,
+ TableCell,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+ type TableVariant,
+} from "~/components/primitives/Table";
+import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
+import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
+import { ScheduleTypeIcon, scheduleTypeName } from "~/components/runs/v3/ScheduleType";
+import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
+import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
+import { $replica } from "~/db.server";
+import { useEnvironment } from "~/hooks/useEnvironment";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { useSearchParams } from "~/hooks/useSearchParam";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
+import { ScheduleListPresenter } from "~/presenters/v3/ScheduleListPresenter.server";
+import type { loader as scheduleDetailLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route";
+import type { loader as scheduleNewLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
+import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
+import {
+ TaskDetailPresenter,
+ type TaskActivity,
+ type TaskDetail,
+} from "~/presenters/v3/TaskDetailPresenter.server";
+import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
+import { requireUser } from "~/services/session.server";
+import {
+ EnvironmentParamSchema,
+ v3BillingPath,
+ v3CreateBulkActionPath,
+ v3EnvironmentPath,
+ v3NewSchedulePath,
+ v3RunsPath,
+ v3SchedulePath,
+ v3SchedulesAddOnPath,
+ v3TestTaskPath,
+} from "~/utils/pathBuilder";
+
+export const meta: MetaFunction = ({ data }) => {
+ const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug;
+ return [
+ { title: slug ? `${slug} | Scheduled tasks | Trigger.dev` : "Scheduled task | Trigger.dev" },
+ ];
+};
+
+const ParamsSchema = EnvironmentParamSchema.extend({
+ taskParam: z.string(),
+});
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUser(request);
+ const userId = user.id;
+ const { organizationSlug, projectParam, envParam, taskParam } = ParamsSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) throw new Response("Project not found", { status: 404 });
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) throw new Response("Environment not found", { status: 404 });
+
+ const url = new URL(request.url);
+ const period = url.searchParams.get("period") ?? undefined;
+ const fromStr = url.searchParams.get("from");
+ const toStr = url.searchParams.get("to");
+ const from = fromStr ? parseInt(fromStr, 10) : undefined;
+ const to = toStr ? parseInt(toStr, 10) : undefined;
+ const cursor = url.searchParams.get("cursor") ?? undefined;
+ const directionRaw = url.searchParams.get("direction") ?? undefined;
+ const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined;
+
+ const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
+ project.organizationId,
+ "standard"
+ );
+
+ const taskPresenter = new TaskDetailPresenter($replica, clickhouse);
+ const task = await taskPresenter.findTask({
+ environmentId: environment.id,
+ environmentType: environment.type,
+ taskSlug: taskParam,
+ expectedTriggerSource: "SCHEDULED",
+ });
+
+ if (!task) throw new Response("Scheduled task not found", { status: 404 });
+
+ const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d" });
+
+ const activity = taskPresenter
+ .getActivity({
+ environmentId: environment.id,
+ taskSlug: task.slug,
+ from: time.from,
+ to: time.to,
+ })
+ .catch(() => ({ data: [], statuses: [] } satisfies TaskActivity));
+
+ const pageRaw = parseInt(url.searchParams.get("page") ?? "1", 10);
+ const schedulesPage = Number.isFinite(pageRaw) && pageRaw > 0 ? pageRaw : 1;
+
+ // Resolved synchronously — the bottom usage bar reads `limits` and
+ // `canPurchaseSchedules` directly from it, and the limit-exceeded
+ // intercept on the "Create schedule" button needs the same.
+ const scheduleList = await new ScheduleListPresenter()
+ .call({
+ userId,
+ projectId: project.id,
+ environmentId: environment.id,
+ tasks: [task.slug],
+ page: schedulesPage,
+ })
+ .catch(() => null);
+
+ const runList = new NextRunListPresenter($replica, clickhouse)
+ .call(project.organizationId, environment.id, {
+ userId,
+ projectId: project.id,
+ tasks: [task.slug],
+ period,
+ from,
+ to,
+ cursor,
+ direction,
+ })
+ .catch(() => null);
+
+ return typeddefer({
+ task,
+ activity,
+ scheduleList,
+ runList,
+ });
+};
+
+export default function Page() {
+ const { task, activity, scheduleList, runList } = useTypedLoaderData();
+ const organization = useOrganization();
+ const project = useProject();
+ const environment = useEnvironment();
+
+ const scheduledTasksListingPath = v3EnvironmentPath(organization, project, environment);
+ const testPath = v3TestTaskPath(organization, project, environment, {
+ taskIdentifier: task.slug,
+ });
+
+ const filters: TaskRunListSearchFilters = useMemo(() => ({ tasks: [task.slug] }), [task.slug]);
+
+ const search = useSearchParams();
+ const openScheduleId = search.value("schedule");
+ const openSchedule = useCallback(
+ (friendlyId: string) => search.replace({ schedule: friendlyId }),
+ [search]
+ );
+ const closeSchedule = useCallback(() => search.del("schedule"), [search]);
+
+ const isCreatingSchedule = search.has("createSchedule");
+ 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
+ // "Create schedule" button's limit-exceeded intercept.
+ const plan = useCurrentPlan();
+ const limits = scheduleList?.limits;
+ const requiresUpgrade =
+ !!plan?.v3Subscription?.plan &&
+ !!limits &&
+ limits.used >= plan.v3Subscription.plan.limits.schedules.number &&
+ !plan.v3Subscription.plan.limits.schedules.canExceed;
+ const canUpgrade =
+ !!plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.schedules.canExceed;
+ const isAtLimit = !!limits && limits.used >= limits.limit;
+
+ return (
+
+
+
+
+ {task.slug}
+
+ }
+ />
+
+
+
+
+
+ {/* Top bar — title on the left; actions + TimeFilter + pagination on the right.
+ h-10 matches the right-hand sidebar header height. */}
+
+
Runs
+
+
+
+
+ View all runs
+
+
+ Bulk replay…
+
+
+
+ {(list) => (list ? : null)}
+
+
+
+
+
+
+ {/* Activity chart */}
+
+
+
+ Runs by status
+
+
}>
+
}>
+ {(result) =>
}
+
+
+
+
+
+
+
+
+
+ {/* Runs table */}
+
+
+
}>
+
}>
+ {(list) =>
+ list ? (
+
+
+
+ ) : (
+
+ )
+ }
+
+
+
+
+
+
+ {/* Schedules usage bar — pinned to the bottom of the main panel
+ via the grid-rows-[auto_1fr_auto] above. */}
+ {scheduleList ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * "Create schedule" button with a limit-exceeded intercept. When the project
+ * is already at its schedules limit, clicking opens a dialog explaining the
+ * limit and offering Purchase / Upgrade / Request, mirroring the behavior
+ * that lived on the (now-removed) standalone Schedules listing page.
+ */
+function CreateScheduleButton({
+ isAtLimit,
+ limits,
+ canUpgrade,
+ canPurchaseSchedules,
+ extraSchedules,
+ maxScheduleQuota,
+ planScheduleLimit,
+ schedulePricing,
+ onCreate,
+ disabled,
+}: {
+ isAtLimit: boolean;
+ limits: { used: number; limit: number } | undefined;
+ canUpgrade: boolean;
+ canPurchaseSchedules: boolean;
+ extraSchedules: number;
+ maxScheduleQuota: number;
+ planScheduleLimit: number;
+ schedulePricing: { stepSize: number; centsPerStep: number } | null;
+ onCreate: () => void;
+ disabled?: boolean;
+}) {
+ const organization = useOrganization();
+ const addOnPath = v3SchedulesAddOnPath(organization);
+
+ if (isAtLimit && limits) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function CreateScheduleSheet({
+ open,
+ organization,
+ project,
+ environment,
+ defaultTaskIdentifier,
+ onClose,
+}: {
+ open: boolean;
+ organization: ReturnType;
+ project: ReturnType;
+ environment: ReturnType;
+ defaultTaskIdentifier: string;
+ onClose: () => void;
+}) {
+ const fetcher = useTypedFetcher();
+ const newPath = v3NewSchedulePath(organization, project, environment);
+
+ useEffect(() => {
+ if (open) fetcher.load(newPath);
+ }, [open, newPath]);
+
+ const data = fetcher.data;
+ const isLoading = fetcher.state === "loading" || (open && !data);
+
+ return (
+ !o && onClose()}>
+ e.preventDefault()}
+ >
+ {isLoading || !data ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function ScheduleSheet({
+ openScheduleId,
+ organization,
+ project,
+ environment,
+ onClose,
+}: {
+ openScheduleId: string | undefined;
+ organization: ReturnType;
+ project: ReturnType;
+ environment: ReturnType;
+ onClose: () => void;
+}) {
+ const fetcher = useTypedFetcher();
+ const detailPath = openScheduleId
+ ? v3SchedulePath(organization, project, environment, { friendlyId: openScheduleId })
+ : undefined;
+
+ useEffect(() => {
+ if (detailPath) fetcher.load(detailPath);
+ }, [detailPath]);
+
+ const schedule = fetcher.data?.schedule;
+ const isLoading = fetcher.state === "loading" || (!!openScheduleId && !schedule);
+
+ return (
+ !open && onClose()}>
+ e.preventDefault()}
+ >
+ {isLoading || !schedule ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+type LoaderData = ReturnType>;
+
+function ScheduledTaskDetailSidebar({
+ task,
+ testPath,
+ scheduleList,
+ onSelectSchedule,
+}: { task: TaskDetail; testPath: string; onSelectSchedule: (friendlyId: string) => void } & Pick<
+ LoaderData,
+ "scheduleList"
+>) {
+ return (
+
+
+
+
+ {task.slug}
+
+
+ Test schedule
+
+
+
+
+
+ Identifier
+
+
+
+
+
+ File path
+
+
+
+
+
+ Type
+
+ Scheduled task
+
+
+
+ Created
+
+
+
+
+
+
+
Schedules
+
+ {scheduleList ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+type ScheduleRow = {
+ id: string;
+ friendlyId: string;
+ type: "DECLARATIVE" | "IMPERATIVE";
+ cron: string;
+ cronDescription: string;
+ externalId: string | null;
+ nextRun: Date;
+ lastRun: Date | undefined;
+ active: boolean;
+};
+
+function SchedulesMiniTable({
+ schedules,
+ variant,
+ onSelectSchedule,
+}: {
+ schedules: ScheduleRow[];
+ variant?: TableVariant;
+ onSelectSchedule: (friendlyId: string) => void;
+}) {
+ if (schedules.length === 0) {
+ return (
+
+
+
+
+ No schedules attached to this task yet.
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Schedule ID
+ Type
+ Cron
+ External ID
+ Next run
+ Last run
+ Status
+
+
+
+ {schedules.map((schedule) => {
+ const open = () => onSelectSchedule(schedule.friendlyId);
+ return (
+
+
+ {schedule.friendlyId}
+
+
+
+
+ {scheduleTypeName(schedule.type)}
+
+
+
+ {schedule.cron}
+
+
+ {schedule.externalId ? (
+ {schedule.externalId}
+ ) : (
+ –
+ )}
+
+
+
+
+
+ {schedule.lastRun ? (
+
+ ) : (
+ Never
+ )}
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+const STATUS_COLOR: Record = {
+ COMPLETED: "#28BF5C",
+ RUNNING: "#3B82F6",
+ FAILED: "#E11D48",
+ CANCELED: "#878C99",
+};
+
+function ActivityChart({ activity }: { activity: TaskActivity }) {
+ const chartConfig: ChartConfig = useMemo(() => {
+ const cfg: ChartConfig = {};
+ for (const status of activity.statuses) {
+ cfg[status] = {
+ label: status.charAt(0) + status.slice(1).toLowerCase(),
+ color: STATUS_COLOR[status] ?? "#9CA3AF",
+ };
+ }
+ return cfg;
+ }, [activity.statuses]);
+
+ const { xAxisFormatter, xAxisTicks } = useMemo(() => {
+ const data = activity.data;
+ const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0;
+ const oneDay = 24 * 60 * 60 * 1000;
+ const showTime = range <= oneDay;
+
+ const formatter = (value: number) => {
+ const date = new Date(value);
+ return showTime
+ ? date.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ timeZone: "UTC",
+ })
+ : date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ timeZone: "UTC",
+ });
+ };
+
+ const ticks = showTime
+ ? undefined
+ : data.filter((d) => new Date(d.bucket).getUTCHours() === 0).map((d) => d.bucket);
+
+ return { xAxisFormatter: formatter, xAxisTicks: ticks };
+ }, [activity.data]);
+
+ const tooltipLabelFormatter = useMemo(() => {
+ const data = activity.data;
+ const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0;
+ const oneDay = 24 * 60 * 60 * 1000;
+ const isSubDayBucket = bucketMs > 0 && bucketMs < oneDay;
+
+ return (_label: string, payload: { payload?: { bucket?: number } }[]) => {
+ const ts = payload?.[0]?.payload?.bucket;
+ if (typeof ts !== "number" || !Number.isFinite(ts)) return _label;
+ const date = new Date(ts);
+ return isSubDayBucket
+ ? date.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ timeZone: "UTC",
+ })
+ : date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ timeZone: "UTC",
+ });
+ };
+ }, [activity.data]);
+
+ return (
+
+
+
+ );
+}
+
+function ActivityChartSkeleton() {
+ return (
+
+ {Array.from({ length: 42 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+function TableLoading() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx
new file mode 100644
index 00000000000..f23af0f823e
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx
@@ -0,0 +1,495 @@
+import { type MetaFunction } from "@remix-run/react";
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
+import { Suspense, useMemo } from "react";
+import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
+import { z } from "zod";
+import { BeakerIcon } from "~/assets/icons/BeakerIcon";
+import { TaskIcon } from "~/assets/icons/TaskIcon";
+import { MachineLabelCombo } from "~/components/MachineLabelCombo";
+import { PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { DirectionSchema, ListPagination } from "~/components/ListPagination";
+import { LinkButton } from "~/components/primitives/Buttons";
+import { Card } from "~/components/primitives/charts/Card";
+import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound";
+import { CopyableText } from "~/components/primitives/CopyableText";
+import { DateTime } from "~/components/primitives/DateTime";
+import { Header2 } from "~/components/primitives/Headers";
+import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import * as Property from "~/components/primitives/PropertyTable";
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from "~/components/primitives/Resizable";
+import { Spinner } from "~/components/primitives/Spinner";
+import { TextLink } from "~/components/primitives/TextLink";
+import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
+import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
+import { $replica } from "~/db.server";
+import { useEnvironment } from "~/hooks/useEnvironment";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
+import {
+ TaskDetailPresenter,
+ type TaskActivity,
+ type TaskDetail,
+} from "~/presenters/v3/TaskDetailPresenter.server";
+import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
+import { requireUser } from "~/services/session.server";
+import {
+ EnvironmentParamSchema,
+ v3EnvironmentPath,
+ v3QueuesPath,
+ v3TestTaskPath,
+} from "~/utils/pathBuilder";
+
+export const meta: MetaFunction = ({ data }) => {
+ const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug;
+ return [{ title: slug ? `${slug} | Tasks | Trigger.dev` : "Task | Trigger.dev" }];
+};
+
+const ParamsSchema = EnvironmentParamSchema.extend({
+ taskParam: z.string(),
+});
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUser(request);
+ const userId = user.id;
+ const { organizationSlug, projectParam, envParam, taskParam } = ParamsSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) throw new Response("Project not found", { status: 404 });
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) throw new Response("Environment not found", { status: 404 });
+
+ const url = new URL(request.url);
+ const period = url.searchParams.get("period") ?? undefined;
+ const fromStr = url.searchParams.get("from");
+ const toStr = url.searchParams.get("to");
+ const from = fromStr ? parseInt(fromStr, 10) : undefined;
+ const to = toStr ? parseInt(toStr, 10) : undefined;
+ const cursor = url.searchParams.get("cursor") ?? undefined;
+ const directionRaw = url.searchParams.get("direction") ?? undefined;
+ const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined;
+ const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0);
+
+ const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
+ project.organizationId,
+ "standard"
+ );
+
+ const presenter = new TaskDetailPresenter($replica, clickhouse);
+ const task = await presenter.findTask({
+ environmentId: environment.id,
+ environmentType: environment.type,
+ taskSlug: taskParam,
+ expectedTriggerSource: "STANDARD",
+ });
+
+ if (!task) throw new Response("Task not found", { status: 404 });
+
+ const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d" });
+
+ const activity = presenter
+ .getActivity({
+ environmentId: environment.id,
+ taskSlug: task.slug,
+ from: time.from,
+ to: time.to,
+ })
+ .catch(() => ({ data: [], statuses: [] } satisfies TaskActivity));
+
+ const runList = new NextRunListPresenter($replica, clickhouse)
+ .call(project.organizationId, environment.id, {
+ userId,
+ projectId: project.id,
+ tasks: [task.slug],
+ versions: versions.length > 0 ? versions : undefined,
+ period,
+ from,
+ to,
+ cursor,
+ direction,
+ })
+ .catch(() => null);
+
+ return typeddefer({
+ task,
+ activity,
+ runList,
+ });
+};
+
+export default function Page() {
+ const { task, activity, runList } = useTypedLoaderData();
+ const organization = useOrganization();
+ const project = useProject();
+ const environment = useEnvironment();
+
+ const tasksListingPath = v3EnvironmentPath(organization, project, environment);
+ const testPath = v3TestTaskPath(organization, project, environment, {
+ taskIdentifier: task.slug,
+ });
+ const queuesPath = v3QueuesPath(organization, project, environment);
+
+ return (
+
+
+
+
+ {task.slug}
+
+ }
+ />
+
+
+
+
+
+ {/* Top bar — title on the left; TimeFilter + pagination on the right.
+ h-10 matches the right-hand sidebar header height. */}
+
+
Runs
+
+
+
+
+ {(list) => (list ? : null)}
+
+
+
+
+
+
+ {/* Activity chart */}
+
+
+
+ Runs by status
+
+
}>
+
}>
+ {(result) =>
}
+
+
+
+
+
+
+
+
+
+ {/* Runs table */}
+
+
+
}>
+
}>
+ {(list) =>
+ list ? (
+
+
+
+ ) : (
+
+ )
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function TaskDetailSidebar({
+ task,
+ testPath,
+ queuesPath,
+}: {
+ task: TaskDetail;
+ testPath: string;
+ queuesPath: string;
+}) {
+ const showExportName = task.exportName && task.exportName !== task.slug;
+ const retrySummary = formatRetrySummary(task.retry);
+
+ return (
+
+
+
+
+ {task.slug}
+
+
+ Test task
+
+
+
+
+
+ Identifier
+
+
+
+
+
+ File path
+
+
+
+
+ {showExportName ? (
+
+ Export name
+
+
+
+
+ ) : null}
+ {task.description ? (
+
+ Description
+
+ {task.description}
+
+
+ ) : null}
+
+ Type
+
+ Standard task
+
+
+ {task.workerVersion ? (
+
+ Version
+
+
+ {task.workerVersion}
+
+
+
+ ) : null}
+ {task.queue ? (
+
+ Queue
+
+
+
{task.queue.name}
+
+ Concurrency: {task.queue.concurrencyLimit ?? "Unlimited"}
+ {task.queue.paused ? " · Paused" : ""}
+
+
+
+
+ ) : null}
+
+ Machine
+
+
+
+
+
+ Max duration
+
+
+ {task.maxDurationInSeconds
+ ? `${task.maxDurationInSeconds}s (${formatDurationMilliseconds(
+ task.maxDurationInSeconds * 1000,
+ { style: "short" }
+ )})`
+ : "–"}
+
+
+
+
+ TTL
+
+ {task.ttl ?? "–"}
+
+
+
+ Retry
+
+ {retrySummary}
+
+
+
+ Payload schema
+
+ {task.hasPayloadSchema ? "Yes" : "–"}
+
+
+
+ Created
+
+
+
+
+
+
+
+ );
+}
+
+function formatRetrySummary(retry: TaskDetail["retry"]): string {
+ if (!retry || retry.maxAttempts === undefined) return "–";
+ if (retry.maxAttempts <= 1) return "Disabled";
+ return `${retry.maxAttempts} attempts`;
+}
+
+const STATUS_COLOR: Record = {
+ COMPLETED: "#28BF5C",
+ RUNNING: "#3B82F6",
+ FAILED: "#E11D48",
+ CANCELED: "#878C99",
+};
+
+function ActivityChart({ activity }: { activity: TaskActivity }) {
+ const chartConfig: ChartConfig = useMemo(() => {
+ const cfg: ChartConfig = {};
+ for (const status of activity.statuses) {
+ cfg[status] = {
+ label: status.charAt(0) + status.slice(1).toLowerCase(),
+ color: STATUS_COLOR[status] ?? "#9CA3AF",
+ };
+ }
+ return cfg;
+ }, [activity.statuses]);
+
+ const { xAxisFormatter, xAxisTicks } = useMemo(() => {
+ const data = activity.data;
+ const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0;
+ const oneDay = 24 * 60 * 60 * 1000;
+ const showTime = range <= oneDay;
+
+ // ClickHouse buckets are aligned to UTC, so we format and pick ticks in
+ // UTC. Using local time here causes off-by-one day labels and a tick
+ // filter that matches zero buckets in any timezone other than UTC.
+ const formatter = (value: number) => {
+ const date = new Date(value);
+ return showTime
+ ? date.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ timeZone: "UTC",
+ })
+ : date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ timeZone: "UTC",
+ });
+ };
+
+ // For multi-day ranges with sub-day buckets, only label the midnight
+ // bucket on each day so we don't get repeated date labels across the
+ // multiple sub-day buckets within a single day.
+ const ticks = showTime
+ ? undefined
+ : data.filter((d) => new Date(d.bucket).getUTCHours() === 0).map((d) => d.bucket);
+
+ return { xAxisFormatter: formatter, xAxisTicks: ticks };
+ }, [activity.data]);
+
+ const tooltipLabelFormatter = useMemo(() => {
+ const data = activity.data;
+ const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0;
+ const oneDay = 24 * 60 * 60 * 1000;
+ const isSubDayBucket = bucketMs > 0 && bucketMs < oneDay;
+
+ return (_label: string, payload: { payload?: { bucket?: number } }[]) => {
+ const ts = payload?.[0]?.payload?.bucket;
+ if (typeof ts !== "number" || !Number.isFinite(ts)) return _label;
+ const date = new Date(ts);
+ return isSubDayBucket
+ ? date.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ timeZone: "UTC",
+ })
+ : date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ timeZone: "UTC",
+ });
+ };
+ }, [activity.data]);
+
+ return (
+
+
+
+ );
+}
+
+function ActivityChartSkeleton() {
+ return (
+
+ {Array.from({ length: 42 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+function TableLoading() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx
index 6fc50a41280..0e0e0feaba5 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx
@@ -1,4 +1,5 @@
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import { ClipboardIcon } from "@heroicons/react/24/outline";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
@@ -7,6 +8,7 @@ import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
@@ -26,6 +28,7 @@ export function AIPayloadTabContent({
placeholder,
examplePromptsOverride,
isAgent = false,
+ showExamplePromptsHeader = true,
}: {
onPayloadGenerated: (payload: string) => void;
payloadSchema?: unknown;
@@ -35,6 +38,7 @@ export function AIPayloadTabContent({
placeholder?: string;
examplePromptsOverride?: string[];
isAgent?: boolean;
+ showExamplePromptsHeader?: boolean;
}) {
const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
@@ -43,6 +47,7 @@ export function AIPayloadTabContent({
const [error, setError] = useState(null);
const [showThinking, setShowThinking] = useState(false);
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
+ const [lastPayload, setLastPayload] = useState(null);
const textareaRef = useRef(null);
const abortControllerRef = useRef(null);
@@ -62,6 +67,7 @@ export function AIPayloadTabContent({
setError(null);
setShowThinking(true);
setLastResult(null);
+ setLastPayload(null);
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@@ -156,6 +162,7 @@ export function AIPayloadTabContent({
case "result":
if (event.success) {
onPayloadGenerated(event.payload);
+ setLastPayload(event.payload);
setPrompt("");
setLastResult("success");
} else {
@@ -191,20 +198,22 @@ export function AIPayloadTabContent({
}
}, [error]);
- const examplePrompts = examplePromptsOverride ?? (payloadSchema
- ? [
- "Generate a valid payload",
- "Generate a payload with edge cases",
- "Generate a minimal payload with only required fields",
- ]
- : [
- "Generate a simple JSON payload",
- "Generate a payload with nested objects",
- "Generate a payload with an array of items",
- ]);
+ const examplePrompts =
+ examplePromptsOverride ??
+ (payloadSchema
+ ? [
+ "Generate a valid payload",
+ "Generate a payload with edge cases",
+ "Generate a minimal payload with only required fields",
+ ]
+ : [
+ "Generate a simple JSON payload",
+ "Generate a payload with nested objects",
+ "Generate a payload with an array of items",
+ ]);
return (
-
+
-
-
-
-
- {isLoading ? (
-
- ) : lastResult === "success" ? (
-
- ) : lastResult === "error" ? (
-
- ) : null}
-
- {isLoading
- ? "AI is thinking…"
- : lastResult === "success"
- ? "Payload generated"
- : lastResult === "error"
- ? "Generation failed"
- : "AI response"}
-
-
+
+
+
{isLoading ? (
-
- ) : (
-
- )}
+
+ ) : lastResult === "success" ? (
+
+ ) : lastResult === "error" ? (
+
+ ) : null}
+
+ {isLoading
+ ? "AI is thinking…"
+ : lastResult === "success"
+ ? "Payload generated"
+ : lastResult === "error"
+ ? "Generation failed"
+ : "AI response"}
+
-
-
{thinking}}>
- {thinking}
-
+
+ {lastResult === "success" && lastPayload && (
+ {
+ if (lastPayload) {
+ void navigator.clipboard.writeText(lastPayload);
+ }
+ }}
+ className="rounded p-1 text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
+ >
+
+
+ }
+ />
+ )}
+ {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ setIsLoading(false);
+ setShowThinking(false);
+ setThinking("");
+ }}
+ className="rounded p-1 text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
+ >
+
+
+ }
+ />
+
+ {thinking}}>
+ {thinking}
+
+
)}
@@ -348,7 +394,9 @@ export function AIPayloadTabContent({
{/* Example prompts */}
-
Example prompts
+ {showExamplePromptsHeader && (
+
Example prompts
+ )}
{examplePrompts.map((example) => (