@@ -395,6 +408,7 @@ const filterTypes = [
{ name: "schedule", title: "Schedule ID", icon:
},
{ name: "bulk", title: "Bulk action", icon:
},
{ name: "error", title: "Error ID", icon:
},
+ { name: "source", title: "Source", icon:
},
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
@@ -448,6 +462,7 @@ function AppliedFilters({ bulkActions }: RunFiltersProps) {
+
>
);
}
@@ -482,6 +497,8 @@ function Menu(props: MenuProps) {
return
props.setFilterType(undefined)} {...props} />;
case "error":
return props.setFilterType(undefined)} {...props} />;
+ case "source":
+ return props.setFilterType(undefined)} {...props} />;
}
}
@@ -1739,3 +1756,101 @@ function AppliedErrorIdFilter() {
);
}
+
+const sourceOptions: { value: TaskTriggerSource; title: string }[] = [
+ { value: "STANDARD", title: "Standard" },
+ { value: "SCHEDULED", title: "Scheduled" },
+ { value: "AGENT", title: "Agent" },
+];
+
+function SourceDropdown({
+ trigger,
+ clearSearchValue,
+ searchValue,
+ onClose,
+}: {
+ trigger: ReactNode;
+ clearSearchValue: () => void;
+ searchValue: string;
+ onClose?: () => void;
+}) {
+ const { values, replace } = useSearchParams();
+
+ const handleChange = (values: string[]) => {
+ clearSearchValue();
+ replace({ sources: values, cursor: undefined, direction: undefined });
+ };
+
+ const filtered = useMemo(() => {
+ return sourceOptions.filter((item) =>
+ item.title.toLowerCase().includes(searchValue.toLowerCase())
+ );
+ }, [searchValue]);
+
+ return (
+
+ {trigger}
+ {
+ if (onClose) {
+ onClose();
+ return false;
+ }
+ return true;
+ }}
+ >
+
+
+ {filtered.map((item, index) => (
+
+ }
+ shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
+ >
+ {item.title}
+
+ ))}
+
+
+
+ );
+}
+
+function AppliedSourceFilter() {
+ const { values, del } = useSearchParams();
+ const sources = values("sources");
+
+ if (sources.length === 0 || sources.every((v) => v === "")) {
+ return null;
+ }
+
+ return (
+
+ {(search, setSearch) => (
+ }>
+ }
+ value={appliedSummary(
+ sources.map(
+ (v) => sourceOptions.find((o) => o.value === v)?.title ?? v
+ )
+ )}
+ onRemove={() => del(["sources", "cursor", "direction"])}
+ variant="secondary/small"
+ />
+
+ }
+ searchValue={search}
+ clearSearchValue={() => setSearch("")}
+ />
+ )}
+
+ );
+}
diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx
index 346fd25eee2..bf8337baa10 100644
--- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx
+++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx
@@ -55,8 +55,10 @@ import {
filterableTaskRunStatuses,
TaskRunStatusCombo,
} from "./TaskRunStatus";
+import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
+import type { TaskTriggerSource } from "@trigger.dev/database";
type RunsTableProps = {
total: number;
@@ -352,6 +354,10 @@ export function TaskRunsTable({
+
{run.taskIdentifier}
{run.rootTaskRunId === null ? Root : null}
diff --git a/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx b/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx
index 8d81e2f36c3..dc61644e14c 100644
--- a/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx
+++ b/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx
@@ -1,4 +1,4 @@
-import { ClockIcon } from "@heroicons/react/20/solid";
+import { ClockIcon, CpuChipIcon } from "@heroicons/react/20/solid";
import type { TaskTriggerSource } from "@trigger.dev/database";
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
import { cn } from "~/utils/cn";
@@ -19,6 +19,11 @@ export function TaskTriggerSourceIcon({
);
}
+ case "AGENT": {
+ return (
+
+ );
+ }
}
}
@@ -30,5 +35,8 @@ export function taskTriggerSourceDescription(source: TaskTriggerSource) {
case "SCHEDULED": {
return "Scheduled task";
}
+ case "AGENT": {
+ return "Agent";
+ }
}
}
diff --git a/apps/webapp/app/components/sessions/v1/CloseSessionDialog.tsx b/apps/webapp/app/components/sessions/v1/CloseSessionDialog.tsx
new file mode 100644
index 00000000000..7feba8e6db5
--- /dev/null
+++ b/apps/webapp/app/components/sessions/v1/CloseSessionDialog.tsx
@@ -0,0 +1,72 @@
+import { XCircleIcon } from "@heroicons/react/24/solid";
+import { DialogClose } from "@radix-ui/react-dialog";
+import { Form, useNavigation } from "@remix-run/react";
+import { Button } from "~/components/primitives/Buttons";
+import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
+import { FormButtons } from "~/components/primitives/FormButtons";
+import { Input } from "~/components/primitives/Input";
+import { Label } from "~/components/primitives/Label";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { SpinnerWhite } from "~/components/primitives/Spinner";
+
+type CloseSessionDialogProps = {
+ sessionParam: string;
+ environmentId: string;
+ redirectPath: string;
+};
+
+export function CloseSessionDialog({
+ sessionParam,
+ environmentId,
+ redirectPath,
+}: CloseSessionDialogProps) {
+ const navigation = useNavigation();
+
+ const formAction = `/resources/sessions/${encodeURIComponent(sessionParam)}/close`;
+ const isLoading = navigation.formAction === formAction;
+
+ return (
+
+ Close this session?
+
+
+ Closing a session is permanent. The session will no longer accept new input or trigger
+ new runs. Any in-flight run continues until it finishes on its own.
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/sessions/v1/SessionFilters.tsx b/apps/webapp/app/components/sessions/v1/SessionFilters.tsx
new file mode 100644
index 00000000000..9c13b7b4b3f
--- /dev/null
+++ b/apps/webapp/app/components/sessions/v1/SessionFilters.tsx
@@ -0,0 +1,764 @@
+import * as Ariakit from "@ariakit/react";
+import {
+ CpuChipIcon,
+ FingerPrintIcon,
+ TagIcon,
+ XMarkIcon,
+} from "@heroicons/react/20/solid";
+import { Form } from "@remix-run/react";
+import { ListFilterIcon } from "lucide-react";
+import { type ReactNode, useCallback, useMemo, useState } from "react";
+import { z } from "zod";
+import { StatusIcon } from "~/assets/icons/StatusIcon";
+import { TaskIcon } from "~/assets/icons/TaskIcon";
+import { AppliedFilter } from "~/components/primitives/AppliedFilter";
+import { Input } from "~/components/primitives/Input";
+import { Label } from "~/components/primitives/Label";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import {
+ ComboBox,
+ SelectButtonItem,
+ SelectItem,
+ SelectList,
+ SelectPopover,
+ SelectProvider,
+ SelectTrigger,
+ shortcutFromIndex,
+} from "~/components/primitives/Select";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "~/components/primitives/Tooltip";
+import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
+import { useSearchParams } from "~/hooks/useSearchParam";
+import { Button } from "../../primitives/Buttons";
+import {
+ appliedSummary,
+ FilterMenuProvider,
+ TimeFilter,
+} from "../../runs/v3/SharedFilters";
+import {
+ allSessionStatuses,
+ descriptionForSessionStatus,
+ SessionStatusCombo,
+ sessionStatusTitle,
+} from "./SessionStatus";
+
+const StringOrStringArray = z.preprocess(
+ (value) => (typeof value === "string" ? [value] : value),
+ z.array(z.string()).optional()
+);
+
+export const SessionStatus = z.enum(allSessionStatuses);
+
+export const SessionListSearchFilters = z.object({
+ cursor: z.string().optional(),
+ direction: z.enum(["forward", "backward"]).optional(),
+ statuses: z.preprocess(
+ (value) => (typeof value === "string" ? [value] : value),
+ SessionStatus.array().optional()
+ ),
+ types: StringOrStringArray,
+ taskIdentifiers: StringOrStringArray,
+ externalId: z.string().optional(),
+ tags: StringOrStringArray,
+ period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
+ from: z.coerce.number().optional(),
+ to: z.coerce.number().optional(),
+});
+
+export type SessionListSearchFilters = z.infer;
+export type SessionListSearchFilterKey = keyof SessionListSearchFilters;
+
+export function getSessionFiltersFromSearchParams(
+ searchParams: URLSearchParams
+): SessionListSearchFilters {
+ function listOrUndefined(key: string) {
+ const values = searchParams.getAll(key).filter((v) => v.length > 0);
+ return values.length > 0 ? values : undefined;
+ }
+
+ const params = {
+ cursor: searchParams.get("cursor") ?? undefined,
+ direction: searchParams.get("direction") ?? undefined,
+ statuses: listOrUndefined("statuses"),
+ types: listOrUndefined("types"),
+ taskIdentifiers: listOrUndefined("taskIdentifiers"),
+ externalId: searchParams.get("externalId") ?? undefined,
+ tags: listOrUndefined("tags"),
+ period: searchParams.get("period") ?? undefined,
+ from: searchParams.get("from") ?? undefined,
+ to: searchParams.get("to") ?? undefined,
+ };
+
+ const parsed = SessionListSearchFilters.safeParse(params);
+ if (!parsed.success) {
+ return {};
+ }
+ return parsed.data;
+}
+
+type SessionFiltersProps = {
+ hasFilters: boolean;
+ possibleTypes?: string[];
+};
+
+export function SessionFilters(props: SessionFiltersProps) {
+ const location = useOptimisticLocation();
+ const searchParams = new URLSearchParams(location.search);
+ const hasFilters =
+ searchParams.has("statuses") ||
+ searchParams.has("types") ||
+ searchParams.has("taskIdentifiers") ||
+ searchParams.has("externalId") ||
+ searchParams.has("tags");
+
+ return (
+
+
+
+
+ {hasFilters && (
+
+ )}
+
+ );
+}
+
+const filterTypes = [
+ {
+ name: "statuses",
+ title: "Status",
+ icon: ,
+ },
+ { name: "types", title: "Type", icon: },
+ {
+ name: "taskIdentifiers",
+ title: "Task",
+ icon: ,
+ },
+ {
+ name: "externalId",
+ title: "External ID",
+ icon: ,
+ },
+ { name: "tags", title: "Tags", icon: },
+] as const;
+
+type FilterType = (typeof filterTypes)[number]["name"];
+
+const shortcut = { key: "f" };
+
+function FilterMenu(props: SessionFiltersProps) {
+ const [filterType, setFilterType] = useState();
+
+ const filterTrigger = (
+
+
+
+ }
+ variant={"secondary/small"}
+ shortcut={shortcut}
+ tooltipTitle={"Filter sessions"}
+ >
+ Filter
+
+ );
+
+ return (
+