chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,215 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { useFetcher, useNavigate } from "@remix-run/react";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { Input } from "~/components/primitives/Input";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
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";
import { cn } from "~/utils/cn";
import { objectToSearchParams } from "~/utils/searchParams";
import { type TaskRunListSearchFilters } from "./RunFilters";
type AIFilterResult =
| {
success: true;
filters: TaskRunListSearchFilters;
explanation?: string;
}
| {
success: false;
error: string;
suggestions?: string[];
};
export function AIFilterInput() {
const [text, setText] = useState("");
const [isFocused, setIsFocused] = useState(false);
const navigate = useNavigate();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const inputRef = useRef<HTMLInputElement>(null);
const fetcher = useFetcher<AIFilterResult>();
useEffect(() => {
if (fetcher.data?.success && fetcher.state === "loading") {
setText("");
setIsFocused(false);
const searchParams = objectToSearchParams(fetcher.data.filters);
if (!searchParams) {
return;
}
navigate(`${location.pathname}?${searchParams.toString()}`, { replace: true });
if (inputRef.current) {
inputRef.current.focus();
}
}
}, [fetcher.data, navigate]);
const isLoading = fetcher.state === "submitting";
return (
<fetcher.Form
className="flex items-center gap-2"
action={`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/ai-filter`}
method="post"
>
<ErrorPopover error={fetcher.data?.success === false ? fetcher.data.error : undefined}>
<motion.div
initial={{ width: "auto" }}
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
className="relative h-6 min-w-44"
>
<AnimatePresence>
{isFocused && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "linear" }}
className="animated-gradient-glow-small pointer-events-none absolute inset-0 h-6"
/>
)}
</AnimatePresence>
<div className="absolute inset-0 left-0 top-0 h-6">
<Input
type="text"
name="text"
variant="secondary-small"
placeholder="Describe your filters…"
value={text}
onChange={(e) => setText(e.target.value)}
disabled={isLoading}
fullWidth
ref={inputRef}
className={cn(
"disabled:text-text-dimmed/50",
isFocused && "placeholder:text-text-dimmed/70"
)}
containerClassName="has-disabled:opacity-100"
onKeyDown={(e) => {
if (e.key === "Enter" && text.trim() && !isLoading) {
e.preventDefault();
const form = e.currentTarget.closest("form");
if (form) {
form.requestSubmit();
}
}
if (e.key === "Escape") {
e.stopPropagation();
setText("");
e.currentTarget.blur();
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => {
if (text.length === 0 || !isLoading) {
setIsFocused(false);
}
}}
icon={<AISparkleIcon className="size-4" />}
accessory={
isLoading ? (
<Spinner
color={{
background: "rgba(99, 102, 241, 1)",
foreground: "rgba(217, 70, 239, 1)",
}}
className="size-4 opacity-80"
/>
) : text.length > 0 ? (
<div className="-mr-1 flex items-center gap-1.5">
<ShortcutKey
shortcut={{ key: "enter" }}
variant="medium"
className="border-none"
/>
<SimpleTooltip
asChild
button={
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setText("")}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-surface-control hover:text-text-bright"
>
<XMarkIcon className="size-3" />
</button>
}
content={
<div className="flex items-center gap-1">
<span className="text-text-dimmed">Clear field</span>
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
</div>
}
className="px-2 py-1.5 text-xs"
disableHoverableContent
/>
</div>
) : undefined
}
/>
</div>
</motion.div>
</ErrorPopover>
</fetcher.Form>
);
}
function ErrorPopover({
children,
error,
durationMs = 10_000,
}: {
children: React.ReactNode;
error?: string;
durationMs?: number;
}) {
const [isOpen, setIsOpen] = useState(false);
const timeout = useRef<NodeJS.Timeout | undefined>();
useEffect(() => {
if (error) {
setIsOpen(true);
}
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => {
setIsOpen(false);
}, durationMs);
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};
}, [error, durationMs]);
return (
<Popover open={isOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
className="w-(--radix-popover-trigger-width) min-w-(--radix-popover-trigger-width) max-w-(--radix-popover-trigger-width) border border-error/20 bg-[#2F1D24] px-3 py-2 text-xs text-text-dimmed"
>
{error}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,59 @@
import { NoSymbolIcon } 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 { Paragraph } from "~/components/primitives/Paragraph";
import { SpinnerWhite } from "~/components/primitives/Spinner";
type AbortBulkActionDialogProps = {
// The abort action route to POST to (the bulk action detail path).
formAction: string;
// Fired on submit so a parent controlling the Radix Dialog can close it
// without wrapping the submit button in `DialogClose` — that wrapper races
// submit (close fires first, unmounts the form, and the abort POST never
// lands). Optional so uncontrolled call sites still type-check.
onAbortSubmitted?: () => void;
};
export function AbortBulkActionDialog({
formAction,
onAbortSubmitted,
}: AbortBulkActionDialogProps) {
const navigation = useNavigation();
const isLoading = navigation.formAction === formAction && navigation.formMethod === "POST";
return (
<DialogContent key="abort">
<DialogHeader>Abort this bulk action?</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
Aborting stops this bulk action from processing any remaining runs. Runs it has already
processed won't be affected.
</Paragraph>
<FormButtons
confirmButton={
<Form action={formAction} method="post" onSubmit={() => onAbortSubmitted?.()}>
<Button
type="submit"
variant="danger/medium"
LeadingIcon={isLoading ? SpinnerWhite : NoSymbolIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Aborting..." : "Abort bulk action"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant={"tertiary/medium"}>Close</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
);
}
@@ -0,0 +1,310 @@
import * as Ariakit from "@ariakit/react";
import { Squares2X2Icon, XMarkIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import type { BatchTaskRunStatus } from "@trigger.dev/database";
import { type ReactNode, useRef } from "react";
import { z } from "zod";
import { StatusIcon } from "~/assets/icons/StatusIcon";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
shortcutFromIndex,
} from "~/components/primitives/Select";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
import { Button } from "../../primitives/Buttons";
import {
allBatchStatuses,
BatchStatusCombo,
batchStatusTitle,
descriptionForBatchStatus,
} from "./BatchStatus";
import {
appliedSummary,
FilterMenuProvider,
IdFilterDropdown,
type IdFilterDropdownProps,
TimeFilter,
} from "./SharedFilters";
export const BatchStatus = z.enum(allBatchStatuses);
export const BatchListFilters = z.object({
cursor: z.string().optional(),
direction: z.enum(["forward", "backward"]).optional(),
statuses: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
BatchStatus.array().optional()
),
id: z.string().optional(),
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
});
export type BatchListFilters = z.infer<typeof BatchListFilters>;
type BatchFiltersProps = {
hasFilters: boolean;
};
export function BatchFilters(props: BatchFiltersProps) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("statuses") ||
searchParams.has("id") ||
searchParams.has("period") ||
searchParams.has("from") ||
searchParams.has("to");
return (
<div className="flex flex-row flex-wrap items-center gap-1.5">
<PermanentStatusFilter />
<PermanentBatchIdFilter />
<TimeFilter shortcut={{ key: "d" }} />
{hasFilters && (
<Form className="-ml-1 h-6">
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</div>
);
}
const statuses = allBatchStatuses.map((status) => ({
title: batchStatusTitle(status),
value: status,
}));
function StatusDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ statuses: values, cursor: undefined, direction: undefined });
};
return (
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<SelectList>
{statuses.map((item, index) => (
<SelectItem
key={item.value}
value={item.value}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="group flex w-full flex-col py-0">
<BatchStatusCombo status={item.value} iconClassName="animate-none" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={9}>
<Paragraph variant="extra-small">
{descriptionForBatchStatus(item.value)}
</Paragraph>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const statusShortcut = { key: "s" };
function PermanentStatusFilter() {
const { values, del } = useSearchParams();
const statuses = values("statuses");
const hasStatuses = statuses.length > 0;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: statusShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasStatuses ? (
<AppliedFilter
label="Status"
icon={<StatusIcon className="size-3.5" />}
value={appliedSummary(
statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus))
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1 rounded border border-border-bright bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-border-brighter group-hover:bg-surface-control">
<div className="grid size-4 place-items-center">
<div className="size-[75%] rounded-full border-2 border-text-bright" />
</div>
<span>Status</span>
</div>
)}
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by status</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={statusShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
const validateBatchId = makeFriendlyIdValidator("batch", "Batch");
function BatchIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
) {
return (
<IdFilterDropdown
{...props}
label="Batch ID"
placeholder="batch_"
paramKey="id"
validate={validateBatchId}
/>
);
}
const batchIdShortcut = { key: "b" };
function PermanentBatchIdFilter() {
const { value, del } = useSearchParams();
const batchId = value("id");
const hasBatchId = batchId !== undefined;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: batchIdShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<BatchIdDropdown
trigger={
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasBatchId ? (
<AppliedFilter
label="Batch ID"
icon={<Squares2X2Icon className="size-3.5" />}
value={batchId}
onRemove={() => del(["id", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1.5 rounded border border-border-bright bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-border-brighter group-hover:bg-surface-control">
<Squares2X2Icon className="size-3.5" />
<span>Batch ID</span>
</div>
)}
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by batch ID</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={batchIdShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -0,0 +1,106 @@
import { CheckCircleIcon, ExclamationTriangleIcon, XCircleIcon } from "@heroicons/react/20/solid";
import type { BatchTaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export const allBatchStatuses = [
"PROCESSING",
"PENDING",
"COMPLETED",
"PARTIAL_FAILED",
"ABORTED",
] as const satisfies Readonly<Array<BatchTaskRunStatus>>;
const descriptions: Record<BatchTaskRunStatus, string> = {
PROCESSING: "The batch is being processed and runs are being created.",
PENDING: "The batch has child runs that have not yet completed.",
COMPLETED: "All the batch child runs have finished.",
PARTIAL_FAILED: "Some runs failed to be created. Successfully created runs are still executing.",
ABORTED: "The batch was aborted because child tasks could not be triggered.",
};
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
return descriptions[status];
}
export function BatchStatusCombo({
status,
className,
iconClassName,
}: {
status: BatchTaskRunStatus;
className?: string;
iconClassName?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<BatchStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
<BatchStatusLabel status={status} />
</span>
);
}
export function BatchStatusLabel({ status }: { status: BatchTaskRunStatus }) {
return <span className={batchStatusColor(status)}>{batchStatusTitle(status)}</span>;
}
export function BatchStatusIcon({
status,
className,
}: {
status: BatchTaskRunStatus;
className: string;
}) {
switch (status) {
case "PROCESSING":
return <Spinner className={cn(batchStatusColor(status), className)} />;
case "PENDING":
return <Spinner className={cn(batchStatusColor(status), className)} />;
case "COMPLETED":
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
case "PARTIAL_FAILED":
return <ExclamationTriangleIcon className={cn(batchStatusColor(status), className)} />;
case "ABORTED":
return <XCircleIcon className={cn(batchStatusColor(status), className)} />;
default: {
assertNever(status);
}
}
}
export function batchStatusColor(status: BatchTaskRunStatus): string {
switch (status) {
case "PROCESSING":
return "text-blue-500";
case "PENDING":
return "text-pending";
case "COMPLETED":
return "text-success";
case "PARTIAL_FAILED":
return "text-warning";
case "ABORTED":
return "text-error";
default: {
assertNever(status);
}
}
}
export function batchStatusTitle(status: BatchTaskRunStatus): string {
switch (status) {
case "PROCESSING":
return "Processing";
case "PENDING":
return "In progress";
case "COMPLETED":
return "Completed";
case "PARTIAL_FAILED":
return "Partial failure";
case "ABORTED":
return "Aborted";
default: {
assertNever(status);
}
}
}
@@ -0,0 +1,135 @@
import { ArrowPathIcon, CheckCircleIcon, NoSymbolIcon } from "@heroicons/react/20/solid";
import type { BulkActionStatus, BulkActionType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export function BulkActionTypeCombo({
type,
className,
iconClassName,
labelClassName,
}: {
type: BulkActionType;
className?: string;
iconClassName?: string;
labelClassName?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<BulkActionIcon type={type} className={cn("h-4 w-4", iconClassName)} />
<BulkActionLabel type={type} className={labelClassName} />
</span>
);
}
export function BulkActionLabel({ type, className }: { type: BulkActionType; className?: string }) {
return <span className={cn("text-text-dimmed", className)}>{bulkActionTitle(type)}</span>;
}
export function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) {
switch (type) {
case "REPLAY":
return <ArrowPathIcon className={cn(bulkActionClassName(type), className)} />;
case "CANCEL":
return <NoSymbolIcon className={cn(bulkActionClassName(type), className)} />;
default: {
assertNever(type);
}
}
}
export function bulkActionClassName(type: BulkActionType): string {
switch (type) {
case "REPLAY":
return "text-indigo-500";
case "CANCEL":
return "text-rose-500";
default: {
assertNever(type);
}
}
}
export function bulkActionTitle(type: BulkActionType): string {
switch (type) {
case "REPLAY":
return "Replay";
case "CANCEL":
return "Cancel";
default: {
assertNever(type);
}
}
}
export function bulkActionVerb(type: BulkActionType): string {
switch (type) {
case "REPLAY":
return "Replaying";
case "CANCEL":
return "Canceling";
default: {
assertNever(type);
}
}
}
export function BulkActionStatusCombo({
status,
className,
iconClassName,
labelClassName,
}: {
status: BulkActionStatus;
className?: string;
iconClassName?: string;
labelClassName?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<BulkActionStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
<BulkActionStatusLabel status={status} className={labelClassName} />
</span>
);
}
export function BulkActionStatusIcon({
status,
className,
}: {
status: BulkActionStatus;
className: string;
}) {
switch (status) {
case "PENDING":
return <Spinner className={cn("text-pending", className)} />;
case "COMPLETED":
return <CheckCircleIcon className={cn("text-success", className)} />;
case "ABORTED":
return <NoSymbolIcon className={cn("text-error", className)} />;
default: {
assertNever(status);
}
}
}
export function BulkActionStatusLabel({
status,
className,
}: {
status: BulkActionStatus;
className?: string;
}) {
switch (status) {
case "PENDING":
return <span className={cn("text-pending", className)}>In progress</span>;
case "COMPLETED":
return <span className={cn("text-success", className)}>Completed</span>;
case "ABORTED":
return <span className={cn("text-error", className)}>Aborted</span>;
default: {
assertNever(status);
}
}
}
@@ -0,0 +1,66 @@
import { NoSymbolIcon } 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 { Paragraph } from "~/components/primitives/Paragraph";
import { SpinnerWhite } from "~/components/primitives/Spinner";
type CancelRunDialogProps = {
runFriendlyId: string;
redirectPath: string;
// Fired on submit so the parent can close the Radix Dialog without
// wrapping the submit button in `DialogClose` — that wrapper races
// submit (close fires first, unmounts the form, and the cancel POST
// never lands). Optional so existing call sites still type-check.
onCancelSubmitted?: () => void;
};
export function CancelRunDialog({
runFriendlyId,
redirectPath,
onCancelSubmitted,
}: CancelRunDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/taskruns/${runFriendlyId}/cancel`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="cancel">
<DialogHeader>Cancel this run?</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
Canceling a run will stop execution, along with any executing subtasks.
</Paragraph>
<FormButtons
confirmButton={
<Form
action={`/resources/taskruns/${runFriendlyId}/cancel`}
method="post"
onSubmit={() => onCancelSubmitted?.()}
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="danger/medium"
LeadingIcon={isLoading ? SpinnerWhite : NoSymbolIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Canceling..." : "Cancel run"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant={"tertiary/medium"}>Close</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
);
}
@@ -0,0 +1,58 @@
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 { Paragraph } from "~/components/primitives/Paragraph";
import { SpinnerWhite } from "~/components/primitives/Spinner";
type CheckBatchCompletionDialogProps = {
batchId: string;
redirectPath: string;
};
export function CheckBatchCompletionDialog({
batchId,
redirectPath,
}: CheckBatchCompletionDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/batches/${batchId}/check-completion`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="check-completion">
<DialogHeader>Try and resume batch</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
In rare cases, parent runs don't continue after child runs have completed.
</Paragraph>
<Paragraph>
If this doesn't help, please get in touch. We are working on a permanent fix for this.
</Paragraph>
<FormButtons
confirmButton={
<Form action={`/resources/batches/${batchId}/check-completion`} method="post">
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : undefined}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Attempting resume..." : "Attempt resume"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant={"tertiary/medium"}>Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
);
}
@@ -0,0 +1,52 @@
import { CodeBlock } from "~/components/code/CodeBlock";
import { Callout } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
import type { ErrorData } from "~/presenters/v3/DeploymentPresenter.server";
type DeploymentErrorProps = {
errorData: ErrorData;
};
export function DeploymentError({ errorData }: DeploymentErrorProps) {
return (
<div className="mx-3 mb-3 flex flex-col gap-2 rounded-sm border border-rose-500/50 p-3">
<DeploymentErrorHeader title={errorData.name ?? "Error"} titleClassName="text-rose-500" />
{errorData.message && <Callout variant="error">{errorData.message}</Callout>}
{errorData.stack && (
<CodeBlock
showCopyButton={false}
showLineNumbers={false}
code={errorData.stack}
maxLines={20}
showTextWrapping
/>
)}
{errorData.stderr && (
<>
<DeploymentErrorHeader title="Error logs:" />
<CodeBlock
showCopyButton={false}
showLineNumbers={false}
code={errorData.stderr}
maxLines={20}
showTextWrapping
/>
</>
)}
</div>
);
}
function DeploymentErrorHeader({
title,
titleClassName,
}: {
title: string;
titleClassName?: string;
}) {
return (
<div className="flex items-center justify-between">
<Header2 className={titleClassName}>{title}</Header2>
</div>
);
}
@@ -0,0 +1,165 @@
import {
CheckCircleIcon,
ExclamationTriangleIcon,
NoSymbolIcon,
RectangleStackIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import type { WorkerDeploymentStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export function DeploymentStatus({
status,
isBuilt,
className,
}: {
status: WorkerDeploymentStatus;
isBuilt: boolean;
className?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<DeploymentStatusIcon status={status} className="h-4 w-4" />
<DeploymentStatusLabel status={status} isBuilt={isBuilt} />
</span>
);
}
export function DeploymentStatusLabel({
status,
isBuilt,
}: {
status: WorkerDeploymentStatus;
isBuilt: boolean;
}) {
return (
<span className={deploymentStatusClassNameColor(status)}>
{deploymentStatusTitle(status, isBuilt)}
</span>
);
}
export function DeploymentStatusIcon({
status,
className,
}: {
status: WorkerDeploymentStatus;
className: string;
}) {
switch (status) {
case "PENDING":
return (
<RectangleStackIcon className={cn(deploymentStatusClassNameColor(status), className)} />
);
case "INSTALLING":
case "BUILDING":
case "DEPLOYING":
return <Spinner className={cn(deploymentStatusClassNameColor(status), className)} />;
case "DEPLOYED":
return <CheckCircleIcon className={cn(deploymentStatusClassNameColor(status), className)} />;
case "CANCELED":
return <NoSymbolIcon className={cn(deploymentStatusClassNameColor(status), className)} />;
case "FAILED":
return <XCircleIcon className={cn(deploymentStatusClassNameColor(status), className)} />;
case "TIMED_OUT":
return (
<ExclamationTriangleIcon
className={cn(deploymentStatusClassNameColor(status), className)}
/>
);
default: {
assertNever(status);
}
}
}
export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): string {
switch (status) {
case "PENDING":
return "text-text-faint";
case "INSTALLING":
case "BUILDING":
case "DEPLOYING":
return "text-pending";
case "TIMED_OUT":
case "CANCELED":
return "text-text-faint";
case "DEPLOYED":
return "text-success";
case "FAILED":
return "text-error";
default: {
assertNever(status);
}
}
}
export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: boolean): string {
switch (status) {
case "PENDING":
return "Queued…";
case "INSTALLING":
return "Installing…";
case "BUILDING":
return "Building…";
case "DEPLOYING":
return "Deploying…";
case "DEPLOYED":
return "Deployed";
case "CANCELED":
return "Canceled";
case "TIMED_OUT":
if (!isBuilt) {
return "Build timed out";
}
return "Indexing timed out";
case "FAILED":
if (!isBuilt) {
return "Build failed";
}
return "Indexing failed";
default: {
assertNever(status);
}
}
}
// PENDING and CANCELED are not used so are ommited from the UI
export const deploymentStatuses: WorkerDeploymentStatus[] = [
"PENDING",
"INSTALLING",
"BUILDING",
"DEPLOYING",
"DEPLOYED",
"FAILED",
"TIMED_OUT",
"CANCELED",
];
export function deploymentStatusDescription(status: WorkerDeploymentStatus): string {
switch (status) {
case "PENDING":
return "The deployment is queued and waiting to be processed.";
case "INSTALLING":
return "The project dependencies are being installed.";
case "BUILDING":
return "The code is being built and prepared for deployment.";
case "DEPLOYING":
return "The deployment is in progress and tasks are being indexed.";
case "DEPLOYED":
return "The deployment has completed successfully.";
case "CANCELED":
return "The deployment was manually canceled.";
case "FAILED":
return "The deployment encountered an error and could not complete.";
case "TIMED_OUT":
return "The deployment exceeded the maximum allowed time and was stopped.";
default: {
assertNever(status);
}
}
}
@@ -0,0 +1,33 @@
import { NoSymbolIcon, CheckIcon } from "@heroicons/react/20/solid";
type EnabledStatusProps = {
enabled: boolean;
enabledIcon?: React.ComponentType<any>;
disabledIcon?: React.ComponentType<any>;
};
export function EnabledStatus({
enabled,
enabledIcon = CheckIcon,
disabledIcon = NoSymbolIcon,
}: EnabledStatusProps) {
const EnabledIcon = enabledIcon;
const DisabledIcon = disabledIcon;
switch (enabled) {
case true:
return (
<div className="flex items-center gap-1 text-xs text-success">
<EnabledIcon className="size-4" />
Enabled
</div>
);
case false:
return (
<div className="text-dimmed flex items-center gap-1 text-xs">
<DisabledIcon className="size-4" />
Disabled
</div>
);
}
}
@@ -0,0 +1,102 @@
import { formatDuration } from "@trigger.dev/core/v3";
import { useEffect, useState } from "react";
export function LiveTimer({
startTime,
endTime,
updateInterval = 250,
}: {
startTime: Date;
endTime?: Date;
updateInterval?: number;
}) {
const [now, setNow] = useState<Date>();
useEffect(() => {
const interval = setInterval(() => {
const date = new Date();
setNow(date);
if (endTime && date > endTime) {
clearInterval(interval);
}
}, updateInterval);
return () => clearInterval(interval);
}, [startTime, endTime]);
return (
<>
{formatDuration(startTime, endTime ?? now, {
style: "short",
maxDecimalPoints: 0,
units: ["d", "h", "m", "s"],
})}
</>
);
}
export function LiveCountUp({
lastUpdated,
updateInterval = 250,
className,
}: {
lastUpdated: Date;
updateInterval?: number;
className?: string;
}) {
const [now, setNow] = useState<Date>();
useEffect(() => {
const interval = setInterval(() => {
const date = new Date();
setNow(date);
}, updateInterval);
return () => clearInterval(interval);
}, [lastUpdated]);
return (
<>
{formatDuration(lastUpdated, now, {
style: "short",
maxDecimalPoints: 0,
units: ["m", "s"],
})}
</>
);
}
export function LiveCountdown({
endTime,
updateInterval = 100,
}: {
endTime: Date;
updateInterval?: number;
}) {
const [now, setNow] = useState<Date>();
useEffect(() => {
const interval = setInterval(() => {
const date = new Date();
setNow(date);
if (date > endTime) {
clearInterval(interval);
}
}, updateInterval);
return () => clearInterval(interval);
}, [endTime]);
return (
<>
{formatDuration(now, endTime, {
style: "short",
maxDecimalPoints: 0,
units: ["d", "h", "m", "s"],
maxUnits: 4,
})}
</>
);
}
@@ -0,0 +1,78 @@
import { CloudArrowDownIcon } from "@heroicons/react/20/solid";
import { CodeBlock } from "~/components/code/CodeBlock";
import { InlineCode } from "~/components/code/InlineCode";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import { docsPath } from "~/utils/pathBuilder";
export function PacketDisplay({
data,
dataType,
title,
searchTerm,
wrap,
}: {
data: string;
dataType: string;
title: string;
searchTerm?: string;
wrap?: boolean;
}) {
switch (dataType) {
case "application/store": {
return (
<div className="mt-2 flex flex-col">
<Header3>{title}</Header3>
<Paragraph variant="small" className="mb-2">
This {title.toLowerCase()} exceeded the size limit and was automatically offloaded to
object storage. You can retrieve it using{" "}
<InlineCode variant="extra-small">runs.retrieve</InlineCode> or download it directly
below. <TextLink to={docsPath("limits#task-payloads-and-outputs")}>Learn more</TextLink>
.
</Paragraph>
<div>
<LinkButton
LeadingIcon={CloudArrowDownIcon}
to={data}
variant="secondary/small"
download
className="inline-flex text-text-bright"
>
Download {title.toLowerCase()}
</LinkButton>
</div>
</div>
);
}
case "text/plain": {
return (
<CodeBlock
language="markdown"
rowTitle={title}
code={data}
maxLines={20}
showLineNumbers={false}
showTextWrapping
searchTerm={searchTerm}
wrap={wrap}
/>
);
}
default: {
return (
<CodeBlock
language="json"
rowTitle={title}
code={data}
maxLines={20}
showLineNumbers={false}
showTextWrapping
searchTerm={searchTerm}
wrap={wrap}
/>
);
}
}
}
@@ -0,0 +1,148 @@
import { Suspense, useState } from "react";
import { CodeBlock } from "~/components/code/CodeBlock";
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
import { Header3 } from "~/components/primitives/Headers";
import { TextLink } from "~/components/primitives/TextLink";
import { tryPrettyJson } from "./ai/aiHelpers";
import { SpanMetricRow as MetricRow } from "./ai/SpanMetricRow";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { v3PromptPath } from "~/utils/pathBuilder";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server";
import { SpanHorizontalTimeline } from "~/components/runs/v3/SpanHorizontalTimeline";
type PromptTab = "overview" | "input" | "template";
export function PromptSpanDetails({
promptData,
startTime,
duration,
}: {
promptData: PromptSpanData;
startTime?: string | Date;
duration?: number | null;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const promptPath =
organization && project && environment
? v3PromptPath(organization, project, environment, promptData.slug, promptData.version)
: undefined;
const hasInput = !!promptData.input;
const hasTemplate = !!promptData.template;
const availableTabs: PromptTab[] = [
"overview",
...(hasInput ? (["input"] as const) : []),
...(hasTemplate ? (["template"] as const) : []),
];
const [tab, setTab] = useState<PromptTab>("overview");
const labels = promptData.labels
? promptData.labels
.split(",")
.map((l) => l.trim())
.filter(Boolean)
: [];
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 overflow-x-auto px-3 py-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<TabContainer>
{availableTabs.map((t) => (
<TabButton
key={t}
isActive={tab === t}
layoutId="prompt-span"
onClick={() => setTab(t)}
shortcut={
t === "overview" ? { key: "o" } : t === "input" ? { key: "i" } : { key: "t" }
}
>
{t === "overview" ? "Overview" : t === "input" ? "Input" : "Template"}
</TabButton>
))}
</TabContainer>
</div>
<div className="scrollbar-gutter-stable min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
{tab === "overview" && (
<div className="flex flex-col px-3">
{startTime && (
<SpanHorizontalTimeline startTime={startTime} duration={duration ?? null} />
)}
<div className="flex flex-col gap-1 py-2.5">
<div className="flex flex-col text-xs @container">
<MetricRow
label="Prompt"
value={
promptPath ? (
<TextLink to={promptPath}>{promptData.slug}</TextLink>
) : (
promptData.slug
)
}
/>
<MetricRow label="Version" value={`v${promptData.version}`} />
{labels.length > 0 && <MetricRow label="Labels" value={labels.join(", ")} />}
{promptData.model && <MetricRow label="Model" value={promptData.model} />}
</div>
</div>
{promptData.text && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Resolved content</Header3>
<div className="rounded-md border border-grid-bright bg-background-hover/50 px-3.5 py-2">
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
<Suspense
fallback={
<span className="whitespace-pre-wrap">
{promptData.text.length > 300
? promptData.text.slice(0, 300) + "..."
: promptData.text}
</span>
}
>
<StreamdownRenderer>{promptData.text}</StreamdownRenderer>
</Suspense>
</div>
</div>
</div>
)}
</div>
)}
{tab === "input" && hasInput && (
<div className="px-3 py-2.5">
<CodeBlock
code={tryPrettyJson(promptData.input!)}
maxLines={30}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
)}
{tab === "template" && hasTemplate && (
<div className="px-3 py-2.5">
<div className="rounded-md border border-grid-bright bg-background-hover/50 px-3.5 py-2">
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
<Suspense
fallback={<span className="whitespace-pre-wrap">{promptData.template!}</span>}
>
<StreamdownRenderer>{promptData.template!}</StreamdownRenderer>
</Suspense>
</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { TasksIcon } from "~/assets/icons/TasksIcon";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { cn } from "~/utils/cn";
import { RectangleStackIcon } from "@heroicons/react/20/solid";
export function QueueName({
name,
type,
paused,
className,
}: {
name: string;
type: "task" | "custom";
paused?: boolean;
className?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
{type === "task" ? (
<SimpleTooltip
button={
<TasksIcon className={cn("size-[1.125rem] text-blue-500", paused && "opacity-50")} />
}
content={`This queue was automatically created from your "${name}" task`}
/>
) : (
<SimpleTooltip
button={
<RectangleStackIcon
className={cn("size-[1.125rem] text-purple-500", paused && "opacity-50")}
/>
}
content={`This is a custom queue you added in your code.`}
/>
)}
<span className={paused ? "opacity-50" : undefined}>{name}</span>
</span>
);
}
@@ -0,0 +1,22 @@
import { FlagIcon } from "~/assets/icons/RegionIcons";
import { cn } from "~/utils/cn";
type RegionLabelProps = {
region: {
name: string;
location?: string | null;
};
className?: string;
iconClassName?: string;
};
export function RegionLabel({ region, className, iconClassName }: RegionLabelProps) {
return (
<span className={cn("flex items-center gap-1", className)}>
{region.location ? (
<FlagIcon region={region.location} className={cn("size-5", iconClassName)} />
) : null}
{region.name}
</span>
);
}
@@ -0,0 +1,606 @@
import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { RectangleStackIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useActionData, useNavigation, useParams, useSubmit } from "@remix-run/react";
import { MachinePresetName } from "@trigger.dev/core/v3";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type UseDataFunctionReturn, useTypedFetcher } from "remix-typedjson";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { JSONEditor } from "~/components/code/JSONEditor";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Badge } from "~/components/primitives/Badge";
import { Button } from "~/components/primitives/Buttons";
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { DurationPicker } from "~/components/primitives/DurationPicker";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
import { type loader } from "~/routes/resources.taskruns.$runParam.replay";
import { docsPath } from "~/utils/pathBuilder";
import { ReplayRunData } from "~/v3/replayTask";
import { RunTagInput } from "./RunTagInput";
type ReplayRunDialogProps = {
runFriendlyId: string;
failedRedirect: string;
};
export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) {
return (
<DialogContent
key={`replay`}
className="flex h-[85vh] max-h-[85vh] flex-col overflow-hidden px-0 md:max-w-3xl lg:max-w-5xl"
>
<ReplayContent runFriendlyId={runFriendlyId} failedRedirect={failedRedirect} />
</DialogContent>
);
}
function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) {
const replayDataFetcher = useTypedFetcher<typeof loader>();
const isLoading = replayDataFetcher.state === "loading";
const queueFetcher = useTypedFetcher<typeof queuesLoader>();
const [environmentIdOverride, setEnvironmentIdOverride] = useState<string | undefined>(undefined);
useEffect(() => {
const searchParams = new URLSearchParams();
if (environmentIdOverride) {
searchParams.set("environmentIdOverride", environmentIdOverride);
}
replayDataFetcher.load(
`/resources/taskruns/${runFriendlyId}/replay?${searchParams.toString()}`
);
}, [runFriendlyId, environmentIdOverride]);
const params = useParams();
useEffect(() => {
if (params.organizationSlug && params.projectParam && params.envParam) {
const searchParams = new URLSearchParams();
searchParams.set("type", "custom");
searchParams.set("per_page", "100");
let envSlug = params.envParam;
if (environmentIdOverride) {
const environmentOverride = replayDataFetcher.data?.environments.find(
(env) => env.id === environmentIdOverride
);
envSlug = environmentOverride?.slug ?? envSlug;
}
queueFetcher.load(
`/resources/orgs/${params.organizationSlug}/projects/${
params.projectParam
}/env/${envSlug}/queues?${searchParams.toString()}`
);
}
}, [params.organizationSlug, params.projectParam, params.envParam, environmentIdOverride]);
const customQueues = useMemo(() => {
return queueFetcher.data?.queues ?? [];
}, [queueFetcher.data?.queues]);
return (
<div className="flex flex-1 flex-col overflow-hidden">
<DialogHeader className="px-3">Replay this run</DialogHeader>
{isLoading && !replayDataFetcher.data ? (
<div className="flex h-full items-center justify-center p-6">
<Spinner />
</div>
) : replayDataFetcher.data ? (
<ReplayForm
replayData={replayDataFetcher.data}
failedRedirect={failedRedirect}
runFriendlyId={runFriendlyId}
customQueues={customQueues}
environmentIdOverride={environmentIdOverride}
setEnvironmentIdOverride={setEnvironmentIdOverride}
/>
) : (
<>Failed to get run data</>
)}
</div>
);
}
const startingJson = "{\n\n}";
const machinePresets = Object.values(MachinePresetName.enum);
function ReplayForm({
failedRedirect,
runFriendlyId,
replayData,
customQueues,
environmentIdOverride,
setEnvironmentIdOverride,
}: {
failedRedirect: string;
runFriendlyId: string;
replayData: UseDataFunctionReturn<typeof loader>;
customQueues: UseDataFunctionReturn<typeof queuesLoader>["queues"];
environmentIdOverride: string | undefined;
setEnvironmentIdOverride: (environment: string) => void;
}) {
const navigation = useNavigation();
const submit = useSubmit();
const [defaultPayloadJson, setDefaultPayloadJson] = useState<string>(
replayData.payload ?? startingJson
);
const setPayload = useCallback((code: string) => {
setDefaultPayloadJson(code);
}, []);
const currentPayloadJson = useRef<string>(replayData.payload ?? startingJson);
const [defaultMetadataJson, setDefaultMetadataJson] = useState<string>(
replayData.metadata ?? startingJson
);
const setMetadata = useCallback((code: string) => {
setDefaultMetadataJson(code);
}, []);
const currentMetadataJson = useRef<string>(replayData.metadata ?? startingJson);
const formAction = `/resources/taskruns/${runFriendlyId}/replay`;
const isSubmitting = navigation.formAction === formAction;
const editablePayload =
replayData.payloadType === "application/json" ||
replayData.payloadType === "application/super+json";
const [tab, setTab] = useState<"payload" | "metadata">(editablePayload ? "payload" : "metadata");
const { defaultTaskQueue } = replayData;
const queues =
defaultTaskQueue && !customQueues.some((q) => q.id === defaultTaskQueue.id)
? [defaultTaskQueue, ...customQueues]
: customQueues;
const queueItems = queues.map((q) => ({
value: q.type === "task" ? `task/${q.name}` : q.name,
label: q.name,
type: q.type,
paused: q.paused,
}));
const lastSubmission = useActionData();
const [form, fields] = useForm({
id: "replay-task",
lastResult: lastSubmission as any,
onSubmit(event, { formData }) {
event.preventDefault();
if (editablePayload) {
formData.set(fields.payload.name, currentPayloadJson.current);
}
formData.set(fields.metadata.name, currentMetadataJson.current);
submit(formData, { method: "POST", action: formAction });
},
onValidate({ formData }) {
return parseWithZod(formData, { schema: ReplayRunData });
},
});
const {
environment,
payload: _payload,
metadata: _metadata,
delaySeconds,
ttlSeconds,
idempotencyKey,
idempotencyKeyTTLSeconds,
queue,
concurrencyKey,
maxAttempts,
maxDurationSeconds,
tags,
version,
machine,
region,
prioritySeconds,
} = fields;
return (
<Form
action={formAction}
method="post"
className="flex flex-1 flex-col overflow-hidden px-3"
{...getFormProps(form)}
>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<Paragraph className="pt-6">
Replaying will create a new run in the selected environment. You can modify the payload,
metadata and run options.
</Paragraph>
<ResizablePanelGroup
orientation="horizontal"
className="-mx-3 mt-3 w-auto flex-1 border-b border-t border-grid-dimmed"
>
<ResizablePanel id="payload" min="300px">
<div className="mb-3 h-full min-h-40 overflow-y-auto rounded-sm bg-background-deep scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<JSONEditor
className="h-full"
autoFocus
defaultValue={tab === "payload" ? defaultPayloadJson : defaultMetadataJson}
readOnly={false}
basicSetup
onChange={(v) => {
if (tab === "payload") {
currentPayloadJson.current = v;
setPayload(v);
} else {
currentMetadataJson.current = v;
setMetadata(v);
}
}}
height="100%"
min-height="100%"
max-height="100%"
additionalActions={
<TabContainer className="flex grow items-baseline justify-between self-end border-none">
<div className="flex gap-5">
<div className="flex items-center gap-1.5">
<TabButton
disabled={!editablePayload}
isActive={tab === "payload"}
layoutId="replay-editor"
onClick={() => {
setTab("payload");
}}
>
Payload
</TabButton>
{!editablePayload && (
<InfoIconTooltip
content={
<span className="text-sm">
Payload is not editable for runs with{" "}
<TextLink to={docsPath("triggering#large-payloads")}>
large payloads.
</TextLink>
</span>
}
/>
)}
</div>
<TabButton
isActive={tab === "metadata"}
layoutId="replay-editor"
onClick={() => {
setTab("metadata");
}}
>
Metadata
</TabButton>
</div>
</TabContainer>
}
/>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="test-task-options" min="300px" default="300px" max="360px">
<div className="h-full overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<Fieldset className="px-3 py-3">
<Hint>
Options enable you to control the execution behavior of your task.{" "}
<TextLink to={docsPath("triggering#options")}>Read the docs.</TextLink>
</Hint>
<InputGroup>
<Label htmlFor={machine.id} variant="small">
Machine
</Label>
<Select
{...getSelectProps(machine)}
variant="tertiary/small"
placeholder="Select machine type"
dropdownIcon
items={machinePresets}
defaultValue={replayData.machinePreset ?? undefined}
>
{machinePresets.map((machine) => (
<SelectItem key={machine} value={machine}>
{machine}
</SelectItem>
))}
</Select>
<Hint>Overrides the machine preset.</Hint>
<FormError id={machine.errorId}>{machine.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={version.id} variant="small">
Version
</Label>
<Select
{...getSelectProps(version)}
defaultValue="latest"
variant="tertiary/small"
placeholder="Select version"
dropdownIcon
disabled={replayData.disableVersionSelection}
>
{replayData.latestVersions.length === 0 ? (
<SelectItem disabled>No versions available</SelectItem>
) : (
replayData.latestVersions.map((version, i) => (
<SelectItem key={version} value={i === 0 ? "latest" : version}>
{version} {i === 0 && "(latest)"}
</SelectItem>
))
)}
</Select>
{replayData.disableVersionSelection ? (
<Hint>Only the latest version is available in the development environment.</Hint>
) : (
<Hint>Runs task on a specific version.</Hint>
)}
<FormError id={version.errorId}>{version.errors}</FormError>
</InputGroup>
{replayData.regions.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
<Select
{...getSelectProps(region)}
variant="tertiary/small"
placeholder={replayData.disableVersionSelection ? "" : undefined}
dropdownIcon
items={replayData.regions}
defaultValue={replayData.region ?? undefined}
disabled={replayData.disableVersionSelection}
>
{replayData.regions.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.description ? `${r.name}${r.description}` : r.name}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{replayData.disableVersionSelection ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.errors}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
</Label>
{replayData.allowArbitraryQueues ? (
<Input
{...getInputProps(queue, { type: "text" })}
variant="small"
defaultValue={replayData.queue}
/>
) : (
<Select
name={queue.name}
id={queue.id}
placeholder="Select queue"
heading="Filter queues"
variant="tertiary/small"
dropdownIcon
items={queueItems}
filter={{ keys: ["label"] }}
defaultValue={replayData.queue}
>
{(matches) =>
matches.map((queueItem) => (
<SelectItem
key={queueItem.value}
value={queueItem.value}
className="max-w-(--popover-anchor-width)"
icon={
queueItem.type === "task" ? (
<TaskIcon className="size-4 shrink-0 text-blue-500" />
) : (
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
>
<div className="flex w-full min-w-0 items-center justify-between">
<span className="truncate">{queueItem.label}</span>
{queueItem.paused && (
<Badge variant="extra-small" className="ml-1 text-warning">
Paused
</Badge>
)}
</div>
</SelectItem>
))
}
</Select>
)}
<Hint>Assign run to a specific queue.</Hint>
<FormError id={queue.errorId}>{queue.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={tags.id} variant="small">
Tags
</Label>
<RunTagInput
name={tags.name}
id={tags.id}
variant="small"
defaultTags={replayData.runTags}
/>
<Hint>Add tags to easily filter runs.</Hint>
<FormError id={tags.errorId}>{tags.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={maxAttempts.id} variant="small">
Max attempts
</Label>
<Input
{...getInputProps(maxAttempts, { type: "number" })}
className="[&::-webkit-inner-spin-button]:appearance-none"
variant="small"
min={1}
defaultValue={replayData.maxAttempts ?? undefined}
onKeyDown={(e) => {
// only allow entering integers > 1
if (["-", "+", ".", "e", "E"].includes(e.key)) {
e.preventDefault();
}
}}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (value < 1 && e.target.value !== "") {
e.target.value = "1";
}
}}
/>
<Hint>Retries failed runs up to the specified number of attempts.</Hint>
<FormError id={maxAttempts.errorId}>{maxAttempts.errors}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Max duration</Label>
<DurationPicker
name={maxDurationSeconds.name}
id={maxDurationSeconds.id}
defaultValueSeconds={replayData.maxDurationSeconds ?? undefined}
/>
<Hint>Overrides the maximum compute time limit for the run.</Hint>
<FormError id={maxDurationSeconds.errorId}>{maxDurationSeconds.errors}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={idempotencyKey.id} variant="small">
Idempotency key
</Label>
<Input {...getInputProps(idempotencyKey, { type: "text" })} variant="small" />
<FormError id={idempotencyKey.errorId}>{idempotencyKey.errors}</FormError>
<Hint>
Specify an idempotency key to ensure that a task is only triggered once with the
same key.
</Hint>
</InputGroup>
<InputGroup>
<Label variant="small">Idempotency key TTL</Label>
<DurationPicker
name={idempotencyKeyTTLSeconds.name}
id={idempotencyKeyTTLSeconds.id}
/>
<Hint>Keys expire after 30 days by default.</Hint>
<FormError id={idempotencyKeyTTLSeconds.errorId}>
{idempotencyKeyTTLSeconds.errors}
</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={concurrencyKey.id} variant="small">
Concurrency key
</Label>
<Input
{...getInputProps(concurrencyKey, { type: "text" })}
variant="small"
defaultValue={replayData.concurrencyKey ?? undefined}
/>
<Hint>
Limits concurrency by creating a separate queue for each value of the key.
</Hint>
<FormError id={concurrencyKey.errorId}>{concurrencyKey.errors}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Delay</Label>
<DurationPicker name={delaySeconds.name} id={delaySeconds.id} />
<Hint>Delays run by a specific duration.</Hint>
<FormError id={delaySeconds.errorId}>{delaySeconds.errors}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Priority</Label>
<DurationPicker name={prioritySeconds.name} id={prioritySeconds.id} />
<Hint>Sets the priority of the run. Higher values mean higher priority.</Hint>
<FormError id={prioritySeconds.errorId}>{prioritySeconds.errors}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">TTL</Label>
<DurationPicker
name={ttlSeconds.name}
id={ttlSeconds.id}
defaultValueSeconds={replayData.ttlSeconds}
/>
<Hint>Expires the run if it hasn't started within the TTL.</Hint>
<FormError id={ttlSeconds.errorId}>{ttlSeconds.errors}</FormError>
</InputGroup>
<FormError>{form.errors}</FormError>
</Fieldset>
</div>
</ResizablePanel>
</ResizablePanelGroup>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed pt-3.5">
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<div className="flex items-center gap-3">
<InputGroup className="flex flex-row items-center gap-3">
<Label>Replay this run in</Label>
<Select
{...getSelectProps(environment)}
placeholder="Select an environment"
defaultValue={replayData.environment.id}
items={replayData.environments}
dropdownIcon
value={environmentIdOverride}
setValue={setEnvironmentIdOverride}
variant="tertiary/medium"
className="min-w-44"
filter={{
keys: [
(item) => item.type.replace(/\//g, " ").replace(/_/g, " "),
(item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "",
],
}}
text={(value) => {
const env = replayData.environments.find((env) => env.id === value)!;
return (
<div className="flex items-center pl-1 pr-2">
<EnvironmentCombo environment={env} />
</div>
);
}}
>
{(matches) =>
matches.map((env) => (
<SelectItem key={env.id} value={env.id}>
<EnvironmentCombo environment={env} />
</SelectItem>
))
}
</Select>
</InputGroup>
<Button
type="submit"
variant="primary/medium"
LeadingIcon={isSubmitting ? SpinnerWhite : undefined}
disabled={isSubmitting}
shortcut={{ modifiers: ["mod"], key: "enter", enabledOnInputElements: true }}
>
{isSubmitting ? "Replaying..." : "Replay run"}
</Button>
</div>
</div>
</Form>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,258 @@
import {
ClockIcon,
HandRaisedIcon,
RectangleStackIcon,
SparklesIcon,
Squares2X2Icon,
TableCellsIcon,
TagIcon,
WrenchIcon,
} from "@heroicons/react/24/outline";
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
import { InfoIcon } from "~/assets/icons/InfoIcon";
import {
AnthropicIcon,
AzureIcon,
CerebrasIcon,
DeepseekIcon,
GeminiIcon,
LlamaIcon,
MistralIcon,
OpenAIIcon,
PerplexityIcon,
XAIIcon,
} from "~/assets/icons/AiProviderIcons";
import { AttemptIcon } from "~/assets/icons/AttemptIcon";
import { TaskCachedIcon, TaskIcon } from "~/assets/icons/TaskIcon";
import { cn } from "~/utils/cn";
import { tablerIcons } from "~/utils/tablerIcons";
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
import { PauseIcon } from "~/assets/icons/PauseIcon";
import { RunFunctionIcon } from "~/assets/icons/RunFunctionIcon";
import { MiddlewareIcon } from "~/assets/icons/MiddlewareIcon";
import { FunctionIcon } from "~/assets/icons/FunctionIcon";
import { TriggerIcon } from "~/assets/icons/TriggerIcon";
import { PythonLogoIcon } from "~/assets/icons/PythonLogoIcon";
import { TraceIcon } from "~/assets/icons/TraceIcon";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
import { StreamsIcon } from "~/assets/icons/StreamsIcon";
type TaskIconProps = {
name: string | undefined;
spanName: string;
className?: string;
};
type SpanNameIcons = {
matcher: RegExp;
iconName: string;
};
const spanNameIcons: SpanNameIcons[] = [{ matcher: /^prisma:/, iconName: "brand-prisma" }];
export function RunIcon({ name, className, spanName }: TaskIconProps) {
const spanNameIcon = spanNameIcons.find(({ matcher }) => matcher.test(spanName));
if (spanNameIcon) {
if (tablerIcons.has("tabler-" + spanNameIcon.iconName)) {
return <TablerIcon name={"tabler-" + spanNameIcon.iconName} className={className} />;
} else if (
spanNameIcon.iconName.startsWith("tabler-") &&
tablerIcons.has(spanNameIcon.iconName)
) {
return <TablerIcon name={spanNameIcon.iconName} className={className} />;
}
}
if (!name)
return (
<Squares2X2Icon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
if (tablerIcons.has(name)) {
return <TablerIcon name={name} className={className} />;
}
switch (name) {
case "task":
return <TaskIcon className={cn(className, "text-tasks")} />;
case "task-cached":
return <TaskCachedIcon className={cn(className, "text-tasks")} />;
case "agent":
return <CubeSparkleIcon className={cn(className, "text-agents")} />;
case "scheduled":
return <ClockIcon className={cn(className, "text-schedules")} />;
case "attempt":
return (
<AttemptIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "wait":
return <PauseIcon className={cn(className, "text-sky-500")} />;
case "trace":
return (
<TraceIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "tag":
return (
<TagIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "queue":
return <RectangleStackIcon className={cn(className, "text-queues")} />;
case "trigger":
return <TriggerIcon className={cn(className, "text-orange-500")} />;
case "python":
return <PythonLogoIcon className={className} />;
case "wait-token":
return <WaitpointTokenIcon className={cn(className, "text-sky-500")} />;
case "function":
return (
<FunctionIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "query":
return <TableCellsIcon className={cn(className, "text-query")} />;
//log levels
case "debug":
case "log":
case "info":
return (
<InfoIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "warn":
return <InfoIcon className={cn(className, "text-amber-400")} />;
case "error":
return <InfoIcon className={cn(className, "text-error")} />;
case "fatal":
return <HandRaisedIcon className={cn(className, "text-error")} />;
case "task-middleware":
return (
<MiddlewareIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "task-fn-run":
return (
<RunFunctionIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "task-hook-init":
case "task-hook-onStart":
case "task-hook-onStartAttempt":
case "task-hook-onSuccess":
case "task-hook-onWait":
case "task-hook-onResume":
case "task-hook-onComplete":
case "task-hook-cleanup":
case "task-hook-onCancel":
return (
<FunctionIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "task-hook-onFailure":
case "task-hook-catchError":
return <FunctionIcon className={cn(className, "text-error")} />;
case "streams":
return (
<StreamsIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "hero-sparkles":
return (
<SparklesIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "hero-wrench":
return (
<WrenchIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "tabler-brand-anthropic":
case "ai-provider-anthropic":
return (
<AnthropicIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "ai-provider-openai":
return (
<OpenAIIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "ai-provider-gemini":
return (
<GeminiIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "ai-provider-llama":
return (
<LlamaIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "ai-provider-deepseek":
return (
<DeepseekIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "ai-provider-xai":
return (
<XAIIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "ai-provider-perplexity":
return (
<PerplexityIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "ai-provider-cerebras":
return (
<CerebrasIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "ai-provider-mistral":
return (
<MistralIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
case "ai-provider-azure":
return (
<AzureIcon
className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")}
/>
);
}
return (
<InfoIcon className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")} />
);
}
function TablerIcon({ name, className }: { name: string; className?: string }) {
return (
<svg className={cn("stroke-[1.5]", className)}>
<use xlinkHref={`${tablerSpritePath}#${name}`} />
</svg>
);
}
@@ -0,0 +1,249 @@
import { useFetcher } from "@remix-run/react";
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import type { NextRunListItem } from "~/presenters/v3/NextRunListPresenter.server";
import type { loader as childStatusesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.children-statuses";
import { isFinalRunStatus } from "~/v3/taskStatus";
import {
allTaskRunStatuses,
descriptionForTaskRunStatus,
TaskRunStatusCombo,
} from "./TaskRunStatus";
const TOOLTIP_OPEN_DELAY_MS = 400;
const TOOLTIP_POLL_INTERVAL_MS = 3000;
type ChildStatusEntry = { status: NextRunListItem["status"]; count: number };
// Compare status/count pairs so unchanged polling responses don't
// re-render or re-animate the tooltip.
function childStatusesKey(statuses: ChildStatusEntry[]) {
return [...statuses]
.sort((a, b) => a.status.localeCompare(b.status))
.map((entry) => `${entry.status}:${entry.count}`)
.join("|");
}
function areChildStatusesEqual(previous: ChildStatusEntry[] | undefined, next: ChildStatusEntry[]) {
if (previous === undefined) return false;
return childStatusesKey(previous) === childStatusesKey(next);
}
function hasActiveChildStatuses(statuses: ChildStatusEntry[] | undefined) {
if (statuses === undefined) return false;
return statuses.some((entry) => entry.count > 0 && !isFinalRunStatus(entry.status));
}
function shouldPollWhileTooltipOpen(
statuses: ChildStatusEntry[] | undefined,
rootHasFinished: boolean
) {
if (statuses === undefined) return true;
// Empty child statuses while the root is still running can mean
// children have not been created yet, so keep polling.
if (statuses.length === 0) return !rootHasFinished;
// All current children may be final while the root is still running — more
// dependents can still be created.
return hasActiveChildStatuses(statuses) || !rootHasFinished;
}
function ChildStatusBreakdown({
orderedChildStatuses,
}: {
orderedChildStatuses: { status: NextRunListItem["status"]; count: number }[];
}) {
return (
<div className="flex min-w-40 flex-col gap-1 p-1">
<p className="mb-1 text-xs text-text-dimmed">Child run statuses</p>
<AnimatePresence initial={false} mode="popLayout">
{orderedChildStatuses.map((entry) => (
<motion.div
key={entry.status}
layout
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex items-center justify-between gap-2"
>
<TaskRunStatusCombo status={entry.status} />
<motion.span
key={entry.count}
layout
initial={{ opacity: 0.6, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="text-xs tabular-nums text-text-bright"
>
{entry.count}
</motion.span>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
function useChildRunStatusesTooltip({
friendlyId,
hasFinished,
childrenStatusesBasePath,
}: {
friendlyId: string;
hasFinished: boolean;
childrenStatusesBasePath: string;
}) {
const fetcher = useFetcher<typeof childStatusesLoader>({
key: `child-statuses-${friendlyId}`,
});
const fetcherStateRef = useRef(fetcher.state);
fetcherStateRef.current = fetcher.state;
const [childStatuses, setChildStatuses] = useState<ChildStatusEntry[] | undefined>();
const isOpenRef = useRef(false);
const pollIntervalRef = useRef<ReturnType<typeof setInterval>>();
const prevHasFinishedRef = useRef(hasFinished);
const childrenStatusesUrl = useMemo(
() => `${childrenStatusesBasePath}/children-statuses?runIds=${encodeURIComponent(friendlyId)}`,
[childrenStatusesBasePath, friendlyId]
);
const loadChildStatuses = useCallback(() => {
if (fetcherStateRef.current !== "idle") return;
fetcher.load(childrenStatusesUrl);
}, [childrenStatusesUrl, fetcher]);
// Keep the latest loader callback available to the polling interval
// without recreating the interval on every render.
const loadChildStatusesRef = useRef(loadChildStatuses);
loadChildStatusesRef.current = loadChildStatuses;
const stopPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = undefined;
}
}, []);
const startPolling = useCallback(() => {
if (pollIntervalRef.current) return;
pollIntervalRef.current = setInterval(() => {
if (document.visibilityState !== "visible") return;
loadChildStatusesRef.current();
}, TOOLTIP_POLL_INTERVAL_MS);
}, []);
useEffect(() => {
if (!fetcher.data?.runs) return;
const entry = fetcher.data.runs.find((run) => run.friendlyId === friendlyId);
if (!entry) return;
setChildStatuses((previous) =>
areChildStatusesEqual(previous, entry.statuses) ? previous : entry.statuses
);
if (isOpenRef.current && !shouldPollWhileTooltipOpen(entry.statuses, hasFinished)) {
stopPolling();
}
}, [fetcher.data, friendlyId, hasFinished, stopPolling]);
const onOpenChange = useCallback(
(open: boolean) => {
isOpenRef.current = open;
if (open) {
loadChildStatuses();
startPolling();
} else {
stopPolling();
}
},
[loadChildStatuses, startPolling, stopPolling]
);
useEffect(() => {
prevHasFinishedRef.current = hasFinished;
stopPolling();
setChildStatuses(undefined);
if (isOpenRef.current) {
loadChildStatuses();
startPolling();
}
// Only reset when the hovered run changes, not when hasFinished toggles.
// eslint-disable-next-line react-hooks/exhaustive-deps -- friendlyId
}, [friendlyId]);
useEffect(() => {
if (!isOpenRef.current) return;
if (prevHasFinishedRef.current === hasFinished) return;
prevHasFinishedRef.current = hasFinished;
loadChildStatuses();
}, [hasFinished, loadChildStatuses]);
useEffect(() => () => stopPolling(), [stopPolling]);
return {
childStatuses,
onOpenChange,
};
}
export function RunStatusCellTooltip({
friendlyId,
status,
hasFinished,
childrenStatusesBasePath,
}: {
friendlyId: string;
status: NextRunListItem["status"];
hasFinished: boolean;
childrenStatusesBasePath: string;
}) {
const { childStatuses, onOpenChange } = useChildRunStatusesTooltip({
friendlyId,
hasFinished,
childrenStatusesBasePath,
});
const orderedChildStatuses = useMemo(() => {
const childStatusesMap = new Map(
(childStatuses ?? []).map((entry) => [entry.status, entry.count])
);
return allTaskRunStatuses
.map((s) => ({
status: s,
count: childStatusesMap.get(s) ?? 0,
}))
.filter((entry) => entry.count > 0);
}, [childStatuses]);
const hasChildStatuses = orderedChildStatuses.length > 0;
return (
<SimpleTooltip
asChild
delayDuration={TOOLTIP_OPEN_DELAY_MS}
onOpenChange={onOpenChange}
content={
hasChildStatuses ? (
<ChildStatusBreakdown orderedChildStatuses={orderedChildStatuses} />
) : (
descriptionForTaskRunStatus(status)
)
}
disableHoverableContent
button={
<span className="inline-flex min-w-full items-center">
<TaskRunStatusCombo status={status} />
</span>
}
/>
);
}
@@ -0,0 +1,191 @@
import { useCallback, useMemo, useState } from "react";
import tagLeftPath from "./tag-left.svg";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { ClipboardCheckIcon, ClipboardIcon, XIcon } from "lucide-react";
type Tag = string | { key: string; value: string };
export function RunTag({
tag,
to,
tooltip,
action = { type: "copy" },
}: {
tag: string;
action?: { type: "copy" } | { type: "delete"; onDelete: (tag: string) => void };
to?: string;
tooltip?: string;
}) {
const tagResult = useMemo(() => splitTag(tag), [tag]);
const [isHovered, setIsHovered] = useState(false);
// Render the basic tag content
const renderTagContent = () => {
if (typeof tagResult === "string") {
return (
<>
<img src={tagLeftPath} alt="" className="block h-full w-2.25" />
<span className="flex items-center rounded-r-sm border-y border-r border-grid-bright bg-background-bright pr-1.5 text-text-dimmed group-hover:rounded-r-none group-hover:group-has-[[href]]:border-border-bright group-hover:group-has-[[href]]:text-charcoal-300">
{tag}
</span>
</>
);
} else {
return (
<>
<img src={tagLeftPath} alt="" className="block h-full w-2.25" />
<span className="flex items-center border-y border-r border-grid-bright bg-background-bright pr-1.5 text-text-dimmed group-hover:group-has-[[href]]:border-border-bright group-hover:group-has-[[href]]:text-charcoal-300">
{tagResult.key}
</span>
<span className="flex items-center whitespace-nowrap rounded-r-sm border-y border-r border-grid-bright bg-background-hover px-1.5 text-text-dimmed group-hover:rounded-r-none group-hover:group-has-[[href]]:border-border-bright group-hover:group-has-[[href]]:bg-background-raised group-hover:group-has-[[href]]:text-charcoal-300">
{tagResult.value}
</span>
</>
);
}
};
// The main tag content, optionally wrapped in a Link and SimpleTooltip
const tagContent = to ? (
<SimpleTooltip
button={
<Link to={to} className="group shrink-0" onMouseEnter={() => setIsHovered(true)}>
<span className="flex h-6 items-stretch">{renderTagContent()}</span>
</Link>
}
content={tooltip || `Filter by ${tag}`}
disableHoverableContent
/>
) : (
<span className="flex h-6 shrink-0 items-stretch" onMouseEnter={() => setIsHovered(true)}>
{renderTagContent()}
</span>
);
return (
<div className="group relative inline-flex shrink-0" onMouseLeave={() => setIsHovered(false)}>
{tagContent}
{action.type === "delete" ? (
<DeleteButton tag={tag} onDelete={action.onDelete} isHovered={isHovered} />
) : (
<CopyButton textToCopy={tag} isHovered={isHovered} />
)}
</div>
);
}
function CopyButton({ textToCopy, isHovered }: { textToCopy: string; isHovered: boolean }) {
const [copied, setCopied] = useState(false);
const copy = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
},
[textToCopy]
);
return (
<SimpleTooltip
button={
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover",
isHovered ? "flex" : "hidden",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy tag"}
disableHoverableContent
/>
);
}
function DeleteButton({
tag,
onDelete,
isHovered,
}: {
tag: string;
onDelete: (tag: string) => void;
isHovered: boolean;
}) {
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onDelete(tag);
},
[tag, onDelete]
);
return (
<SimpleTooltip
button={
<span
onClick={handleDelete}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover",
isHovered ? "flex" : "hidden",
"text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-rose-400"
)}
>
<XIcon className="size-3.5" />
</span>
}
content="Remove tag"
disableHoverableContent
/>
);
}
/** Takes a string and turns it into a tag
*
* If the string has 12 or fewer alpha characters followed by an underscore or colon then we return an object with a key and value
* Otherwise we return the original string
*
* Special handling for common ID formats and values with special characters.
*/
export function splitTag(tag: string): Tag {
const match = tag.match(/^([a-zA-Z0-9]{1,12})[_:](.*?)$/);
if (!match) return tag;
const [, key, value] = match;
const colonCount = (tag.match(/:/g) || []).length;
const underscoreCount = (tag.match(/_/g) || []).length;
const hasMultipleColons = colonCount > 1 && !tag.includes("_");
const hasMultipleUnderscores = underscoreCount > 1 && !tag.includes(":");
const isLikelyID = hasMultipleColons || hasMultipleUnderscores;
if (!isLikelyID) return { key, value };
const isAlphabeticKey = key.match(/^[a-zA-Z]+$/) !== null;
const hasSpecialFormatChars =
value.includes("-") || value.includes("T") || value.includes("Z") || value.includes("/");
const isSpecialFormat = isAlphabeticKey && hasSpecialFormatChars;
if (isSpecialFormat) return { key, value };
return tag;
}
@@ -0,0 +1,133 @@
import { useCallback, useState, useEffect, type KeyboardEvent } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Input } from "~/components/primitives/Input";
import { RunTag } from "./RunTag";
interface TagInputProps {
id?: string; // used for the hidden input for form submission
name?: string; // used for the hidden input for form submission
defaultTags?: string[];
tags?: string[];
placeholder?: string;
variant?: "small" | "medium";
maxTags?: number;
maxTagLength?: number;
onTagsChange?: (tags: string[]) => void;
}
export function RunTagInput({
id,
name,
defaultTags = [],
tags: controlledTags,
placeholder = "Type and press Enter to add tags",
variant = "small",
maxTags = 10,
maxTagLength = 128,
onTagsChange,
}: TagInputProps) {
// Use controlled tags if provided, otherwise use default
const initialTags = controlledTags ?? defaultTags;
const [tags, setTags] = useState<string[]>(initialTags);
const [inputValue, setInputValue] = useState("");
// Sync internal state with external tag changes
useEffect(() => {
if (controlledTags !== undefined) {
setTags(controlledTags);
}
}, [controlledTags]);
const addTag = useCallback(
(tagText: string) => {
const trimmedTag = tagText.trim();
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
const newTags = [...tags, trimmedTag];
setTags(newTags);
onTagsChange?.(newTags);
}
setInputValue("");
},
[tags, onTagsChange, maxTags]
);
const removeTag = useCallback(
(tagToRemove: string) => {
const newTags = tags.filter((tag) => tag !== tagToRemove);
setTags(newTags);
onTagsChange?.(newTags);
},
[tags, onTagsChange]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
addTag(inputValue);
} else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) {
removeTag(tags[tags.length - 1]);
} else if (e.key === ",") {
e.preventDefault();
}
},
[inputValue, addTag, removeTag, tags]
);
const maxTagsReached = tags.length >= maxTags;
return (
<div className="flex flex-col gap-2">
<input type="hidden" name={name} id={id} value={tags.join(",")} />
<Input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={maxTagsReached ? `A maximum of ${maxTags} tags is allowed` : placeholder}
variant={variant}
disabled={maxTagsReached}
maxLength={maxTagLength}
/>
{tags.length > 0 && (
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
<AnimatePresence mode="popLayout">
{tags.map((tag, i) => (
<motion.div
key={tag}
initial={{
opacity: 0,
scale: 0.8,
}}
animate={{
opacity: 1,
scale: 1,
}}
exit={{
opacity: 0,
scale: 0.7,
y: -10,
transition: {
duration: 0.15,
ease: "easeOut",
},
}}
transition={{
type: "spring",
stiffness: 400,
damping: 25,
duration: 0.15,
}}
>
<RunTag tag={tag} action={{ type: "delete", onDelete: removeTag }} />
</motion.div>
))}
</AnimatePresence>
</div>
)}
</div>
);
}
@@ -0,0 +1,261 @@
import * as Ariakit from "@ariakit/react";
import { ClockIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { useCallback, useRef } from "react";
import { z } from "zod";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { SearchInput } from "~/components/primitives/SearchInput";
import {
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
} from "~/components/primitives/Select";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Button } from "../../primitives/Buttons";
import { ScheduleTypeIcon, scheduleTypeName } from "./ScheduleType";
import { FilterMenuProvider } from "./SharedFilters";
export const ScheduleListFilters = z.object({
page: z.coerce.number().default(1),
tasks: z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
type: z.union([z.literal("declarative"), z.literal("imperative")]).optional(),
search: z.string().optional(),
});
export type ScheduleListFilters = z.infer<typeof ScheduleListFilters>;
type ScheduleFiltersProps = {
possibleTasks: string[];
};
export function ScheduleFilters({ possibleTasks }: ScheduleFiltersProps) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("tasks") || searchParams.has("search") || searchParams.has("type");
return (
<div className="flex flex-row flex-wrap items-center gap-1.5">
<ScheduleSearchInput />
<PermanentTaskFilter possibleTasks={possibleTasks} />
<PermanentTypeFilter />
{hasFilters && <ClearFiltersButton />}
</div>
);
}
function ScheduleSearchInput() {
return <SearchInput placeholder="Search schedules…" resetParams={["page"]} />;
}
const typeShortcut = { key: "y" };
function PermanentTypeFilter() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const currentType = searchParams.get("type") ?? undefined;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: typeShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
const handleChange = useCallback(
(value: string | string[]) => {
const selected = Array.isArray(value) ? value[0] : value;
const params = new URLSearchParams(location.search);
if (!selected || selected === "ALL") {
params.delete("type");
} else {
params.set("type", selected);
}
params.delete("page");
navigate(`${location.pathname}?${params.toString()}`);
},
[location, navigate]
);
const typeLabel = currentType
? scheduleTypeName(currentType.toUpperCase() as "IMPERATIVE" | "DECLARATIVE")
: "All types";
return (
<FilterMenuProvider>
{() => (
<SelectProvider value={currentType ?? "ALL"} setValue={handleChange} virtualFocus={true}>
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
render={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
<AppliedFilter
label="Type"
value={typeLabel}
removable={!!currentType}
onRemove={() => handleChange("ALL")}
variant="secondary/small"
/>
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by type</span>
<ShortcutKey className="size-4 flex-none" shortcut={typeShortcut} variant="small" />
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
<SelectList>
<SelectItem value="ALL" className="text-text-bright">
All types
</SelectItem>
<SelectItem value="declarative">
<div className="flex items-center gap-1">
<ScheduleTypeIcon type="DECLARATIVE" className="text-text-dimmed" />
<span className="text-text-bright">{scheduleTypeName("DECLARATIVE")}</span>
</div>
</SelectItem>
<SelectItem value="imperative">
<div className="flex items-center gap-1">
<ScheduleTypeIcon type="IMPERATIVE" className="text-text-dimmed" />
<span className="text-text-bright">{scheduleTypeName("IMPERATIVE")}</span>
</div>
</SelectItem>
</SelectList>
</SelectPopover>
</SelectProvider>
)}
</FilterMenuProvider>
);
}
const taskShortcut = { key: "t" };
function PermanentTaskFilter({ possibleTasks }: { possibleTasks: string[] }) {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const currentTask = searchParams.get("tasks") ?? undefined;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: taskShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
const handleChange = useCallback(
(value: string | string[]) => {
const selected = Array.isArray(value) ? value[0] : value;
const params = new URLSearchParams(location.search);
if (!selected || selected === "ALL") {
params.delete("tasks");
} else {
params.set("tasks", selected);
}
params.delete("page");
navigate(`${location.pathname}?${params.toString()}`);
},
[location, navigate]
);
const taskLabel = currentTask ?? "All tasks";
return (
<FilterMenuProvider>
{() => (
<SelectProvider value={currentTask ?? "ALL"} setValue={handleChange} virtualFocus={true}>
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
render={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
<AppliedFilter
label="Task"
icon={<ClockIcon className="size-4" />}
value={taskLabel}
removable={!!currentTask}
onRemove={() => handleChange("ALL")}
variant="secondary/small"
/>
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by task</span>
<ShortcutKey className="size-4 flex-none" shortcut={taskShortcut} variant="small" />
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
<SelectPopover className="min-w-0 max-w-[min(360px,var(--popover-available-width))]">
<SelectList>
<SelectItem value="ALL" className="text-text-bright">
All tasks
</SelectItem>
{possibleTasks.map((task) => (
<SelectItem
key={task}
value={task}
icon={<ClockIcon className="size-4 text-schedules" />}
className="text-text-bright"
>
{task}
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
)}
</FilterMenuProvider>
);
}
function ClearFiltersButton() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const clearFilters = useCallback(() => {
const params = new URLSearchParams(location.search);
params.delete("page");
params.delete("tasks");
params.delete("search");
params.delete("type");
navigate(`${location.pathname}?${params.toString()}`);
}, [location, navigate]);
return (
<div className="-ml-1 h-6">
<Button
variant="minimal/small"
onClick={clearFilters}
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</div>
);
}
@@ -0,0 +1,29 @@
import { ArchiveBoxIcon, ArrowsRightLeftIcon } from "@heroicons/react/20/solid";
import type { ScheduleType } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
export function ScheduleTypeCombo({ type, className }: { type: ScheduleType; className?: string }) {
return (
<div className={cn("flex items-center space-x-1", className)}>
<ScheduleTypeIcon type={type} />
<span>{scheduleTypeName(type)}</span>
</div>
);
}
export function ScheduleTypeIcon({ type, className }: { type: ScheduleType; className?: string }) {
switch (type) {
case "IMPERATIVE":
return <ArrowsRightLeftIcon className={cn("size-4", className)} />;
case "DECLARATIVE":
return <ArchiveBoxIcon className={cn("size-4", className)} />;
}
}
export function scheduleTypeName(type: ScheduleType) {
switch (type) {
case "IMPERATIVE":
return "Imperative";
case "DECLARATIVE":
return "Declarative";
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
import { EnvelopeIcon } from "@heroicons/react/20/solid";
import {
exceptionEventEnhancer,
isExceptionSpanEvent,
type ExceptionEventProperties,
type SpanEvent as OtelSpanEvent,
} from "@trigger.dev/core/v3";
import { CodeBlock } from "~/components/code/CodeBlock";
import { Feedback } from "~/components/Feedback";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
type SpanEventsProps = {
spanEvents: OtelSpanEvent[];
};
export function SpanEvents({ spanEvents }: SpanEventsProps) {
const displayableEvents = spanEvents.filter((event) => !event.name.startsWith("trigger.dev/"));
if (displayableEvents.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-4">
{displayableEvents.map((event, index) => (
<SpanEvent key={index} spanEvent={event} />
))}
</div>
);
}
function SpanEventHeader({
title,
titleClassName,
time,
}: {
title: string;
titleClassName?: string;
time: Date;
}) {
return (
<div className="flex items-center justify-between">
<Header3 className={titleClassName}>{title}</Header3>
<Paragraph variant="extra-small">
<DateTimeAccurate date={time} />
</Paragraph>
</div>
);
}
function SpanEvent({ spanEvent }: { spanEvent: OtelSpanEvent }) {
if (isExceptionSpanEvent(spanEvent)) {
return <SpanEventError spanEvent={spanEvent} exception={spanEvent.properties.exception} />;
}
return (
<div className="flex flex-col gap-2">
<SpanEventHeader title={spanEvent.name} time={spanEvent.time} />
{spanEvent.properties && (
<CodeBlock code={JSON.stringify(spanEvent.properties, null, 2)} maxLines={20} />
)}
</div>
);
}
export function SpanEventError({
spanEvent,
exception,
}: {
spanEvent: OtelSpanEvent;
exception: ExceptionEventProperties;
}) {
const enhancedException = exceptionEventEnhancer(exception);
return (
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 px-3 pb-3 pt-2">
<SpanEventHeader
title={enhancedException.type ?? "Error"}
time={spanEvent.time}
titleClassName="text-rose-500"
/>
{enhancedException.message && (
<Callout variant="error">
<pre className="text-wrap font-sans text-sm font-normal text-rose-200">
{enhancedException.message}
</pre>
</Callout>
)}
{enhancedException.link &&
(enhancedException.link.magic === "CONTACT_FORM" ? (
<Feedback
button={
<Button
variant="tertiary/medium"
LeadingIcon={EnvelopeIcon}
leadingIconClassName="text-blue-400"
fullWidth
textAlignLeft
>
{enhancedException.link.name}
</Button>
}
/>
) : (
<Callout variant="docs" to={enhancedException.link.href}>
{enhancedException.link.name}
</Callout>
))}
{enhancedException.stacktrace && (
<CodeBlock
showCopyButton={false}
showLineNumbers={false}
code={enhancedException.stacktrace}
maxLines={20}
/>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { DateTimeAccurate } from "~/components/primitives/DateTime";
function formatSpanDuration(nanoseconds: number): string {
const ms = nanoseconds / 1_000_000;
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const totalSecs = Math.round(ms / 1000);
const mins = Math.floor(totalSecs / 60);
const secs = totalSecs % 60;
return `${mins}m ${secs}s`;
}
export function SpanHorizontalTimeline({
startTime,
duration,
}: {
startTime: string | Date;
duration: number | null;
}) {
const startDate = startTime instanceof Date ? startTime : new Date(startTime);
const endDate = duration != null ? new Date(startDate.getTime() + duration / 1_000_000) : null;
return (
<div className="@container/timeline">
<div className="flex flex-col gap-0.5 px-1 py-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-text-bright">Started</span>
<span className="text-xs font-medium text-text-bright">Finished</span>
</div>
<div className="flex items-center">
<span className="shrink-0 tabular-nums text-xxs text-text-dimmed @[350px]/timeline:text-xs">
<DateTimeAccurate date={startDate} showTooltip={false} />
</span>
<div className="ml-2 h-3 w-px bg-surface-control" />
<div className="h-px flex-1 bg-surface-control" />
{duration != null && (
<span className="shrink-0 tabular-nums px-2 text-xxs text-text-dimmed @[350px]/timeline:text-xs">
{formatSpanDuration(duration)}
</span>
)}
<div className="h-px flex-1 bg-surface-control" />
<div className="mr-2 h-3 w-px bg-surface-control" />
<span className="shrink-0 tabular-nums text-xxs text-text-dimmed @[350px]/timeline:text-xs">
{endDate ? (
<DateTimeAccurate date={endDate} previousDate={startDate} showTooltip={false} />
) : (
<span className="text-text-faint"></span>
)}
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,268 @@
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import type { TaskEventStyle } from "@trigger.dev/core/v3";
import type { TaskEventLevel } from "@trigger.dev/database";
import { Fragment } from "react";
import { cn } from "~/utils/cn";
import { tablerIcons } from "~/utils/tablerIcons";
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
type SpanTitleProps = {
message: string;
isError: boolean;
style: TaskEventStyle;
level: TaskEventLevel;
isPartial: boolean;
size: "small" | "large";
hideAccessory?: boolean;
overrideDimmed?: boolean;
/**
* Mark the span as belonging to an AGENT-kind task so the label renders
* in the agents colour, matching the agent icon in the tree row.
*/
isAgentRun?: boolean;
};
export function SpanTitle(event: SpanTitleProps) {
const textClass = event.isAgentRun ? "text-agents" : eventTextClassName(event);
const finalTextClass =
event.overrideDimmed && textClass === "text-text-dimmed" ? "text-text-bright" : textClass;
// Only dimmed labels brighten on row hover; colored labels (blue/amber/error)
// already carry meaning and should keep their hue.
const hoverClass =
finalTextClass === "text-text-dimmed" ? "group-hover/spannode:text-text-bright" : undefined;
return (
<span className={cn("flex items-center gap-x-2 overflow-x-hidden", finalTextClass, hoverClass)}>
<span className="truncate">{event.message}</span>{" "}
{!event.hideAccessory && (
<SpanAccessory accessory={event.style.accessory} size={event.size} />
)}
</span>
);
}
function SpanAccessory({
accessory,
size,
}: {
accessory: TaskEventStyle["accessory"];
size: SpanTitleProps["size"];
}) {
if (!accessory) {
return null;
}
switch (accessory.style) {
case "codepath": {
return (
<SpanCodePathAccessory
accessory={accessory}
className={cn("overflow-x-hidden", size === "large" ? "text-sm" : "text-xs")}
/>
);
}
case "pills": {
return (
<span className="flex items-center gap-1">
{accessory.items
.filter((item) => typeof item.text === "string")
.map((item, index) => (
<SpanPill key={index} text={item.text} icon={item.icon} />
))}
</span>
);
}
default: {
return (
<span className={cn("flex gap-1")}>
{accessory.items
.filter((item) => typeof item.text === "string")
.map((item, index) => (
<span key={index} className={cn("inline-flex items-center gap-1")}>
{item.text}
</span>
))}
</span>
);
}
}
}
function SpanPill({ text, icon }: { text: string; icon?: string }) {
const hasIcon = icon && tablerIcons.has(icon);
return (
<span className="inline-flex items-center gap-0.5 rounded-full border border-grid-bright bg-background-dimmed px-1.5 py-px text-xxs text-text-dimmed">
{hasIcon && (
<svg className="size-3 stroke-[1.5] text-text-dimmed/70">
<use xlinkHref={`${tablerSpritePath}#${icon}`} />
</svg>
)}
<span className="truncate">{text}</span>
</span>
);
}
export function SpanCodePathAccessory({
accessory,
className,
}: {
accessory: NonNullable<TaskEventStyle["accessory"]>;
className?: string;
}) {
return (
<code
className={cn(
"inline-flex items-center gap-0.5 truncate rounded border border-grid-bright bg-background-bright px-1.5 py-0.5 font-mono text-text-dimmed",
className
)}
>
{accessory.items
.filter((item) => typeof item.text === "string")
.map((item, index, filtered) => (
<Fragment key={index}>
<span className={cn("truncate", "text-text-dimmed")}>{item.text}</span>
{index < filtered.length - 1 && (
<span className="text-text-dimmed">
<ChevronRightIcon className="size-4" />
</span>
)}
</Fragment>
))}
</code>
);
}
function eventTextClassName(event: Pick<SpanTitleProps, "isError" | "style" | "level">) {
// Wait/suspended spans keep their icon and label in the same sky tone so
// the row reads as a single "suspended" unit. Matches `wait` in RunIcon.
if (event.style.icon === "wait") {
return "text-sky-500";
}
switch (event.level) {
case "TRACE": {
return textClassNameForVariant(event.style.variant);
}
case "LOG":
case "INFO":
case "DEBUG": {
return textClassNameForVariant(event.style.variant);
}
case "WARN": {
return "text-amber-400";
}
case "ERROR": {
return "text-error";
}
default: {
return textClassNameForVariant(event.style.variant);
}
}
}
type RunEvent = {
isError: boolean;
style: TaskEventStyle;
level: TaskEventLevel;
isPartial: boolean;
isCancelled: boolean;
};
export function eventBackgroundClassName(event: RunEvent) {
if (event.isError) {
return "bg-error";
}
if (event.isCancelled) {
return "bg-surface-control";
}
switch (event.level) {
case "TRACE": {
return backgroundClassNameForVariant(event.style.variant, event.isPartial);
}
case "LOG":
case "INFO":
case "DEBUG": {
return backgroundClassNameForVariant(event.style.variant, event.isPartial);
}
case "WARN": {
return "bg-amber-400";
}
case "ERROR": {
return "bg-error";
}
default: {
return backgroundClassNameForVariant(event.style.variant, event.isPartial);
}
}
}
export function eventBorderClassName(event: RunEvent) {
if (event.isError) {
return "border-error";
}
if (event.isCancelled) {
return "border-border-bright";
}
switch (event.level) {
case "TRACE": {
return borderClassNameForVariant(event.style.variant, event.isPartial);
}
case "LOG":
case "INFO":
case "DEBUG": {
return borderClassNameForVariant(event.style.variant, event.isPartial);
}
case "WARN": {
return "border-amber-400";
}
case "ERROR": {
return "border-error";
}
default: {
return borderClassNameForVariant(event.style.variant, event.isPartial);
}
}
}
function textClassNameForVariant(variant: TaskEventStyle["variant"]) {
switch (variant) {
case "primary": {
return "text-blue-500";
}
default: {
return "text-text-dimmed";
}
}
}
function backgroundClassNameForVariant(variant: TaskEventStyle["variant"], isPartial: boolean) {
switch (variant) {
case "primary": {
if (isPartial) {
return "bg-blue-500";
}
return "bg-success";
}
default: {
return "bg-surface-control-active";
}
}
}
function borderClassNameForVariant(variant: TaskEventStyle["variant"], isPartial: boolean) {
switch (variant) {
case "primary": {
if (isPartial) {
return "border-blue-500";
}
return "border-success";
}
default: {
return "border-border-brightest";
}
}
}
@@ -0,0 +1,35 @@
import type { InlineCodeVariant } from "~/components/code/InlineCode";
import { InlineCode } from "~/components/code/InlineCode";
import { SpanCodePathAccessory } from "./SpanTitle";
import { cn } from "~/utils/cn";
type TaskPathProps = {
filePath: string;
functionName: string;
className?: string;
};
export function TaskPath({ filePath, functionName, className }: TaskPathProps) {
return (
<SpanCodePathAccessory
accessory={{
items: [{ text: filePath }, { text: functionName }],
}}
className={className}
/>
);
}
type TaskFileNameProps = {
fileName: string;
variant?: InlineCodeVariant;
className?: string;
};
export function TaskFileName({ variant, fileName, className }: TaskFileNameProps) {
return (
<InlineCode variant={variant} className={cn("text-text-dimmed", className)}>
{`${fileName}`}
</InlineCode>
);
}
@@ -0,0 +1,130 @@
import {
CheckCircleIcon,
ClockIcon,
NoSymbolIcon,
RectangleStackIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import type { TaskRunAttemptStatus as TaskRunAttemptStatusType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { HourglassIcon } from "lucide-react";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskRunAttemptStatus } from "~/database-types";
import { cn } from "~/utils/cn";
export const allTaskRunAttemptStatuses = Object.values(
TaskRunAttemptStatus
) as TaskRunAttemptStatusType[];
export type ExtendedTaskAttemptStatus = TaskRunAttemptStatusType | "ENQUEUED";
export function TaskRunAttemptStatusCombo({
status,
className,
}: {
status: ExtendedTaskAttemptStatus | null;
className?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<TaskRunAttemptStatusIcon status={status} className="h-4 w-4" />
<TaskRunAttemptStatusLabel status={status} />
</span>
);
}
export function TaskRunAttemptStatusLabel({
status,
}: {
status: ExtendedTaskAttemptStatus | null;
}) {
return (
<span className={runAttemptStatusClassNameColor(status)}>{runAttemptStatusTitle(status)}</span>
);
}
export function TaskRunAttemptStatusIcon({
status,
className,
}: {
status: ExtendedTaskAttemptStatus | null;
className: string;
}) {
if (status === null) {
return <RectangleStackIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
}
switch (status) {
case "ENQUEUED":
return (
<RectangleStackIcon className={cn(runAttemptStatusClassNameColor(status), className)} />
);
case "PENDING":
return <ClockIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
case "EXECUTING":
return <Spinner className={cn(runAttemptStatusClassNameColor(status), className)} />;
case "PAUSED":
return <HourglassIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
case "FAILED":
return <XCircleIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
case "CANCELED":
return <NoSymbolIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
case "COMPLETED":
return <CheckCircleIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
default: {
assertNever(status);
}
}
}
export function runAttemptStatusClassNameColor(status: ExtendedTaskAttemptStatus | null): string {
if (status === null) {
return "text-text-faint";
}
switch (status) {
case "ENQUEUED":
return "text-text-faint";
case "PENDING":
return "text-text-faint";
case "EXECUTING":
return "text-pending";
case "PAUSED":
return "text-text-faint";
case "FAILED":
return "text-error";
case "CANCELED":
return "text-text-faint";
case "COMPLETED":
return "text-success";
default: {
assertNever(status);
}
}
}
export function runAttemptStatusTitle(status: ExtendedTaskAttemptStatus | null): string {
if (status === null) {
return "Enqueued";
}
switch (status) {
case "ENQUEUED":
return "Enqueued";
case "PENDING":
return "Pending";
case "EXECUTING":
return "Executing";
case "PAUSED":
return "Waiting";
case "FAILED":
return "Failed";
case "CANCELED":
return "Canceled";
case "COMPLETED":
return "Completed";
default: {
assertNever(status);
}
}
}
@@ -0,0 +1,330 @@
import {
ArrowPathIcon,
BoltSlashIcon,
BugAntIcon,
CheckCircleIcon,
ClockIcon,
FireIcon,
NoSymbolIcon,
PauseCircleIcon,
RectangleStackIcon,
TrashIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import type { TaskRunStatus } from "@trigger.dev/database";
import { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3";
import assertNever from "assert-never";
import { HourglassIcon } from "lucide-react";
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
import { Callout } from "~/components/primitives/Callout";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export const allTaskRunStatuses = [
"DELAYED",
"WAITING_FOR_DEPLOY",
"PENDING_VERSION",
"PENDING",
"DEQUEUED",
"EXECUTING",
"RETRYING_AFTER_FAILURE",
"WAITING_TO_RESUME",
"COMPLETED_SUCCESSFULLY",
"COMPLETED_WITH_ERRORS",
"CANCELED",
"TIMED_OUT",
"CRASHED",
"PAUSED",
"INTERRUPTED",
"SYSTEM_FAILURE",
"EXPIRED",
] as const satisfies Readonly<Array<TaskRunStatus>>;
export const filterableTaskRunStatuses = [
"PENDING_VERSION",
"DELAYED",
"PENDING",
"DEQUEUED",
"EXECUTING",
"WAITING_TO_RESUME",
"COMPLETED_SUCCESSFULLY",
"COMPLETED_WITH_ERRORS",
"TIMED_OUT",
"CRASHED",
"SYSTEM_FAILURE",
"CANCELED",
"EXPIRED",
] as const satisfies Readonly<Array<TaskRunStatus>>;
const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
DELAYED: "Task has been delayed and is waiting to be executed.",
PENDING: "Task is waiting to be executed.",
PENDING_VERSION: "Run cannot execute until a version includes the task and queue.",
WAITING_FOR_DEPLOY: "Run cannot execute until a version includes the task and queue.",
DEQUEUED: "Task has been dequeued from the queue but is not yet executing.",
EXECUTING: "Task is currently being executed.",
RETRYING_AFTER_FAILURE: "Task is being reattempted after a failure.",
WAITING_TO_RESUME: `You have used a "wait" function. When the wait is complete, the task will resume execution.`,
COMPLETED_SUCCESSFULLY: "Task has been successfully completed.",
CANCELED: "Task has been canceled.",
COMPLETED_WITH_ERRORS: "Task has failed with errors.",
INTERRUPTED: "Task has failed because it was interrupted.",
SYSTEM_FAILURE: "Task has failed due to a system failure.",
PAUSED: "Task has been paused by the user.",
CRASHED: "Task has crashed and won't be retried.",
EXPIRED: "Task has surpassed its ttl and won't be executed.",
TIMED_OUT: "Task has failed because it exceeded its maxDuration.",
};
export const QUEUED_STATUSES = [
"PENDING",
"PENDING_VERSION",
"WAITING_FOR_DEPLOY",
"DELAYED",
] satisfies TaskRunStatus[];
export const RUNNING_STATUSES = [
"DEQUEUED",
"EXECUTING",
"RETRYING_AFTER_FAILURE",
"WAITING_TO_RESUME",
] satisfies TaskRunStatus[];
export function descriptionForTaskRunStatus(status: TaskRunStatus): string {
return taskRunStatusDescriptions[status];
}
export function TaskRunStatusCombo({
status,
className,
iconClassName,
}: {
status: TaskRunStatus;
className?: string;
iconClassName?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<TaskRunStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
<TaskRunStatusLabel status={status} />
</span>
);
}
const statusReasonsToDescription: Record<string, string> = {
NO_DEPLOYMENT: "No deployment or deployment image reference found for deployed run",
NO_WORKER: "No worker found for run",
TASK_NEVER_REGISTERED: "Task never registered",
QUEUE_NOT_FOUND: "Queue not found",
TASK_NOT_IN_LATEST: "Task not in latest version",
BACKGROUND_WORKER_MISMATCH: "Background worker mismatch",
};
export function TaskRunStatusReason({
status,
statusReason,
}: {
status: TaskRunStatus;
statusReason?: string;
}) {
if (status !== "PENDING_VERSION") {
return null;
}
if (!statusReason) {
return null;
}
const description = statusReasonsToDescription[statusReason];
if (!description) {
return null;
}
return (
<Callout to="https://trigger.dev/docs" variant="warning" className="text-sm">
{description}
</Callout>
);
}
export function TaskRunStatusLabel({ status }: { status: TaskRunStatus }) {
return <span className={runStatusClassNameColor(status)}>{runStatusTitle(status)}</span>;
}
export function TaskRunStatusIcon({
status,
className,
}: {
status: TaskRunStatus;
className: string;
}) {
switch (status) {
case "DELAYED":
return <ClockIcon className={cn(runStatusClassNameColor(status), className)} />;
case "PENDING":
return <RectangleStackIcon className={cn(runStatusClassNameColor(status), className)} />;
case "PENDING_VERSION":
case "WAITING_FOR_DEPLOY":
return <RectangleStackIcon className={cn(runStatusClassNameColor(status), className)} />;
case "DEQUEUED":
return <RectangleStackIcon className={cn(runStatusClassNameColor(status), className)} />;
case "EXECUTING":
return <Spinner className={cn(runStatusClassNameColor(status), className)} />;
case "WAITING_TO_RESUME":
return <HourglassIcon className={cn(runStatusClassNameColor(status), className)} />;
case "RETRYING_AFTER_FAILURE":
return <ArrowPathIcon className={cn(runStatusClassNameColor(status), className)} />;
case "PAUSED":
return <PauseCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "CANCELED":
return <NoSymbolIcon className={cn(runStatusClassNameColor(status), className)} />;
case "INTERRUPTED":
return <BoltSlashIcon className={cn(runStatusClassNameColor(status), className)} />;
case "COMPLETED_SUCCESSFULLY":
return <CheckCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "COMPLETED_WITH_ERRORS":
return <XCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "SYSTEM_FAILURE":
return <BugAntIcon className={cn(runStatusClassNameColor(status), className)} />;
case "CRASHED":
return <FireIcon className={cn(runStatusClassNameColor(status), className)} />;
case "EXPIRED":
return <TrashIcon className={cn(runStatusClassNameColor(status), className)} />;
case "TIMED_OUT":
return <TimedOutIcon className={cn(runStatusClassNameColor(status), className)} />;
default: {
assertNever(status);
}
}
}
export function runStatusClassNameColor(status: TaskRunStatus): string {
switch (status) {
case "PENDING":
case "DELAYED":
return "text-text-faint";
case "PENDING_VERSION":
case "WAITING_FOR_DEPLOY":
return "text-amber-500";
case "EXECUTING":
case "RETRYING_AFTER_FAILURE":
case "DEQUEUED":
return "text-pending";
case "WAITING_TO_RESUME":
return "text-text-faint";
case "PAUSED":
return "text-amber-300";
case "CANCELED":
case "EXPIRED":
return "text-text-faint";
case "INTERRUPTED":
return "text-error";
case "COMPLETED_SUCCESSFULLY":
return "text-success";
case "COMPLETED_WITH_ERRORS":
return "text-error";
case "SYSTEM_FAILURE":
return "text-error";
case "CRASHED":
return "text-error";
case "TIMED_OUT":
return "text-error";
default: {
assertNever(status);
}
}
}
export function runStatusTitle(status: TaskRunStatus): RunFriendlyStatus {
return runStatusTitleFromStatus[status];
}
export function runStatusFromFriendlyTitle(friendly: RunFriendlyStatus): TaskRunStatus {
const result = titlesStatusesArray.find(([_, f]) => f === friendly);
if (!result) {
throw new Error(`Unknown friendly status: ${friendly}`);
}
return result[0] as TaskRunStatus;
}
// runFriendlyStatus and RunFriendlyStatus are imported from @trigger.dev/core/v3
// and re-exported here for backward compatibility.
export { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3";
/**
* Check if a value is a valid TaskRunStatus
*/
export function isTaskRunStatus(value: unknown): value is TaskRunStatus {
return typeof value === "string" && allTaskRunStatuses.includes(value as TaskRunStatus);
}
/**
* Check if a value is a valid RunFriendlyStatus
*/
export function isRunFriendlyStatus(value: unknown): value is RunFriendlyStatus {
return typeof value === "string" && runFriendlyStatus.includes(value as RunFriendlyStatus);
}
export const runStatusTitleFromStatus: Record<TaskRunStatus, RunFriendlyStatus> = {
DELAYED: "Delayed",
PENDING: "Queued",
PENDING_VERSION: "Pending version",
WAITING_FOR_DEPLOY: "Pending version",
DEQUEUED: "Dequeued",
EXECUTING: "Executing",
WAITING_TO_RESUME: "Waiting",
RETRYING_AFTER_FAILURE: "Reattempting",
PAUSED: "Paused",
CANCELED: "Canceled",
INTERRUPTED: "Interrupted",
COMPLETED_SUCCESSFULLY: "Completed",
COMPLETED_WITH_ERRORS: "Failed",
SYSTEM_FAILURE: "System failure",
CRASHED: "Crashed",
EXPIRED: "Expired",
TIMED_OUT: "Timed out",
};
const titlesStatusesArray = Object.entries(runStatusTitleFromStatus);
/**
* Chart series color for each TaskRunStatus, mirroring `runStatusClassNameColor`.
* Values are CSS variables (defined in tailwind.css) so they follow the theme;
* only usable in CSS contexts (SVG attributes, chart config styles).
*/
const RUN_STATUS_CHART_COLORS: Record<TaskRunStatus, string> = {
PENDING: "var(--color-run-pending)",
DELAYED: "var(--color-run-delayed)",
PENDING_VERSION: "var(--color-run-pending-version)",
WAITING_FOR_DEPLOY: "var(--color-run-waiting-for-deploy)",
EXECUTING: "var(--color-run-executing)",
RETRYING_AFTER_FAILURE: "var(--color-run-retrying-after-failure)",
DEQUEUED: "var(--color-run-dequeued)",
WAITING_TO_RESUME: "var(--color-run-waiting-to-resume)",
PAUSED: "var(--color-run-paused)",
CANCELED: "var(--color-run-canceled)",
EXPIRED: "var(--color-run-expired)",
INTERRUPTED: "var(--color-run-interrupted)",
COMPLETED_SUCCESSFULLY: "var(--color-run-completed-successfully)",
COMPLETED_WITH_ERRORS: "var(--color-run-completed-with-errors)",
SYSTEM_FAILURE: "var(--color-run-system-failure)",
CRASHED: "var(--color-run-crashed)",
TIMED_OUT: "var(--color-run-timed-out)",
};
/**
* Get the chart color for a run status value. Accepts either a raw TaskRunStatus
* (e.g. "COMPLETED_SUCCESSFULLY") or a friendly name (e.g. "Completed").
* Returns `undefined` when the value is not a recognised status.
*/
export function getRunStatusChartColor(value: string): string | undefined {
if (isTaskRunStatus(value)) {
return RUN_STATUS_CHART_COLORS[value];
}
if (isRunFriendlyStatus(value)) {
return RUN_STATUS_CHART_COLORS[runStatusFromFriendlyTitle(value)];
}
return undefined;
}
@@ -0,0 +1,772 @@
import {
ArrowPathIcon,
ArrowRightIcon,
ClockIcon,
CpuChipIcon,
NoSymbolIcon,
RectangleStackIcon,
} from "@heroicons/react/20/solid";
import { BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
import { useLocation } from "@remix-run/react";
import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { useCallback, useRef } from "react";
import { TasksIcon } from "~/assets/icons/TasksIcon";
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
import { MachineTooltipInfo } from "~/components/MachineTooltipInfo";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Checkbox } from "~/components/primitives/Checkbox";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useRegions } from "~/hooks/useRegions";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import {
type NextRunListAppliedFilters,
type NextRunListItem,
} from "~/presenters/v3/NextRunListPresenter.server";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import { docsPath, v3RunSpanPath, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
import { DateTime } from "../../primitives/DateTime";
import { Paragraph } from "../../primitives/Paragraph";
import { Spinner } from "../../primitives/Spinner";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
type TableVariant,
} from "../../primitives/Table";
import { CancelRunDialog } from "./CancelRunDialog";
import { RegionLabel } from "./RegionLabel";
import { LiveTimer } from "./LiveTimer";
import { ReplayRunDialog } from "./ReplayRunDialog";
import { RunTag } from "./RunTag";
import {
descriptionForTaskRunStatus,
filterableTaskRunStatuses,
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { RunStatusCellTooltip } from "./RunStatusCellTooltip";
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
import type { TaskTriggerSource } from "@trigger.dev/database";
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
type RunsTableProps = {
total: number;
hasFilters: boolean;
filters: NextRunListAppliedFilters;
showJob?: boolean;
runs: NextRunListItem[];
rootOnlyDefault?: boolean;
isLoading?: boolean;
allowSelection?: boolean;
variant?: TableVariant;
disableAdjacentRows?: boolean;
additionalTableState?: Record<string, string>;
showTopBorder?: boolean;
stickyHeader?: boolean;
childrenStatusesBasePath?: string;
/**
* Display-only write:runs flags from the caller's loader. Default true so
* callers that don't pass them (and OSS, where the ability is permissive)
* keep the controls enabled. The cancel/replay action routes enforce
* write:runs regardless.
*/
canCancelRuns?: boolean;
canReplayRuns?: boolean;
};
export function TaskRunsTable({
total,
hasFilters,
filters,
runs,
rootOnlyDefault,
disableAdjacentRows = false,
isLoading = false,
allowSelection = false,
variant = "dimmed",
additionalTableState,
showTopBorder = true,
stickyHeader = false,
childrenStatusesBasePath,
canCancelRuns = true,
canReplayRuns = true,
}: RunsTableProps) {
const regions = useRegions();
const regionByMasterQueue = new Map(regions.map((r) => [r.masterQueue, r] as const));
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
const { isManagedCloud } = useFeatures();
const { value } = useSearchParams();
const location = useOptimisticLocation();
const params = new URLSearchParams(location.search || "");
if (!value("rootOnly")) {
params.set("rootOnly", String(rootOnlyDefault));
}
if (additionalTableState) {
for (const [key, val] of Object.entries(additionalTableState)) {
params.set(key, val);
}
}
const search = params.toString();
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
const tableStateParam = disableAdjacentRows ? "" : encodeURIComponent(search);
const showCompute = isManagedCloud;
const showRegion = environment.type !== "DEVELOPMENT";
const navigateCheckboxes = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>, index: number) => {
//indexes are out by one because of the header row
if (event.key === "ArrowUp" && index > 0) {
checkboxes.current[index - 1]?.focus();
if (event.shiftKey) {
const oldItem = runs.at(index - 1);
const newItem = runs.at(index - 2);
const itemsIds = [oldItem?.friendlyId, newItem?.friendlyId].filter(Boolean);
select(itemsIds);
}
} else if (event.key === "ArrowDown" && index < checkboxes.current.length - 1) {
checkboxes.current[index + 1]?.focus();
if (event.shiftKey) {
const oldItem = runs.at(index - 1);
const newItem = runs.at(index);
const itemsIds = [oldItem?.friendlyId, newItem?.friendlyId].filter(Boolean);
select(itemsIds);
}
}
},
[checkboxes, runs]
);
return (
<Table
variant={variant}
className="max-h-full overflow-y-auto"
showTopBorder={showTopBorder}
stickyHeader={stickyHeader}
>
<TableHeader>
<TableRow>
{allowSelection && (
<TableHeaderCell className="pl-3 pr-0">
{runs.length > 0 && (
<Checkbox
checked={hasAll(runs.map((r) => r.friendlyId))}
onChange={(element) => {
const ids = runs.map((r) => r.friendlyId);
const checked = element.currentTarget.checked;
if (checked) {
select(ids);
} else {
deselect(ids);
}
}}
ref={(r) => {
checkboxes.current[0] = r;
}}
onKeyDown={(event) => navigateCheckboxes(event, 0)}
/>
)}
</TableHeaderCell>
)}
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell
disableTooltipHoverableContent
tooltip={
<div className="flex flex-col divide-y divide-grid-dimmed">
{filterableTaskRunStatuses.map((status) => (
<div
key={status}
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
>
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<TaskRunStatusCombo status={status} />
</div>
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
{descriptionForTaskRunStatus(status)}
</Paragraph>
</div>
))}
</div>
}
>
Status
</TableHeaderCell>
<TableHeaderCell>Started</TableHeaderCell>
<TableHeaderCell
colSpan={3}
disableTooltipHoverableContent
tooltip={
<div className="flex max-w-xs flex-col gap-4 p-1">
<div>
<div className="mb-0.5 flex items-center gap-1.5">
<RectangleStackIcon className="size-4 text-text-dimmed" />
<Header3>Queued duration</Header3>
</div>
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
The amount of time from when the run was created to it starting to run.
</Paragraph>
</div>
<div>
<div className="mb-0.5 flex items-center gap-1.5">
<ClockIcon className="size-4 text-blue-500" /> <Header3>Run duration</Header3>
</div>
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
The total amount of time from the run starting to it finishing. This includes
all time spent waiting.
</Paragraph>
</div>
<div>
<div className="mb-0.5 flex items-center gap-1.5">
<CpuChipIcon className="size-4 text-success" />
<Header3>Compute duration</Header3>
</div>
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
The amount of compute time used in the run. This does not include time spent
waiting.
</Paragraph>
</div>
</div>
}
>
Duration
</TableHeaderCell>
{showCompute && (
<>
<TableHeaderCell>Compute</TableHeaderCell>
</>
)}
<TableHeaderCell className="pl-4" tooltip={<MachineTooltipInfo />}>
Machine
</TableHeaderCell>
<TableHeaderCell>Queue</TableHeaderCell>
{showRegion && <TableHeaderCell>Region</TableHeaderCell>}
<TableHeaderCell>Test</TableHeaderCell>
<TableHeaderCell>Created at</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="max-w-xs p-1">
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
When you want to trigger a task now, but have it run at a later time, you can use
the delay option.
</Paragraph>
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
Runs that are delayed and have not been enqueued yet will display in the dashboard
with a Delayed status.
</Paragraph>
<LinkButton
to={docsPath("v3/triggering")}
variant="docs/small"
LeadingIcon={BookOpenIcon}
className="mt-3"
>
Read docs
</LinkButton>
</div>
}
>
Delayed until
</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="max-w-xs p-1">
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
You can set a TTL (time to live) when triggering a task, which will automatically
expire the run if it hasnt started within the specified time.
</Paragraph>
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
All runs in development have a default ttl of 10 minutes. You can disable this by
setting the ttl option.
</Paragraph>
<LinkButton
to={docsPath("v3/triggering")}
variant="docs/small"
LeadingIcon={BookOpenIcon}
className="mt-3"
>
Read docs
</LinkButton>
</div>
}
>
TTL
</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="max-w-xs p-1">
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
You can add tags to a run and then filter runs using them.
</Paragraph>
<Paragraph variant="small" className="text-wrap! text-text-dimmed" spacing>
You can add tags when triggering a run or inside the run function.
</Paragraph>
<LinkButton
to={docsPath("v3/tags")}
variant="docs/small"
LeadingIcon={BookOpenIcon}
className="mt-3"
>
Read docs
</LinkButton>
</div>
}
>
Tags
</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Go to page</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={showRegion ? 16 : 15}>
{!isLoading && <NoRuns title="No runs found" />}
</TableBlankRow>
) : runs.length === 0 ? (
<BlankState isLoading={isLoading} filters={filters} showRegion={showRegion} />
) : (
runs.map((run, index) => {
const searchParams = new URLSearchParams();
if (tableStateParam) {
searchParams.set("tableState", tableStateParam);
}
const path = v3RunSpanPath(
organization,
project,
run.environment,
run,
{
spanId: run.spanId,
},
searchParams
);
return (
<TableRow key={run.id}>
{allowSelection && (
<TableCell className="pl-3 pr-0">
<Checkbox
checked={has(run.friendlyId)}
onChange={() => {
toggle(run.friendlyId);
}}
ref={(r) => {
checkboxes.current[index + 1] = r;
}}
onKeyDown={(event) => navigateCheckboxes(event, index + 1)}
/>
</TableCell>
)}
<TableCell to={path} isTabbableCell>
<TruncatedCopyableValue value={run.friendlyId} />
</TableCell>
<TableCell to={path}>
<span className="flex items-center gap-x-1">
<TaskTriggerSourceIcon
source={run.taskKind as TaskTriggerSource}
className="size-3.5 flex-none"
/>
{run.taskIdentifier}
{run.rootTaskRunId === null ? <Badge variant="extra-small">Root</Badge> : null}
</span>
</TableCell>
<TableCell to={path}>{run.version ?? ""}</TableCell>
<TableCell to={path}>
{run.rootTaskRunId === null && childrenStatusesBasePath ? (
<RunStatusCellTooltip
friendlyId={run.friendlyId}
status={run.status}
hasFinished={run.hasFinished}
childrenStatusesBasePath={childrenStatusesBasePath}
/>
) : (
<SimpleTooltip
content={descriptionForTaskRunStatus(run.status)}
disableHoverableContent
button={<TaskRunStatusCombo status={run.status} />}
/>
)}
</TableCell>
<TableCell to={path}>
{run.startedAt ? <DateTime date={run.startedAt} /> : ""}
</TableCell>
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
<div className="flex items-center gap-1">
<RectangleStackIcon className="size-4 text-text-dimmed" />
{run.isPending ? (
""
) : run.startedAt ? (
formatDuration(new Date(run.createdAt), new Date(run.startedAt), {
style: "short",
})
) : run.isCancellable ? (
<LiveTimer startTime={new Date(run.createdAt)} />
) : (
formatDuration(new Date(run.createdAt), new Date(run.updatedAt), {
style: "short",
})
)}
</div>
</TableCell>
<TableCell to={path} className="w-[1%]" actionClassName="px-4 tabular-nums">
<div className="flex items-center gap-1">
<ClockIcon className="size-4 text-blue-500" />
{run.startedAt && run.finishedAt ? (
formatDuration(new Date(run.startedAt), new Date(run.finishedAt), {
style: "short",
})
) : run.startedAt ? (
<LiveTimer startTime={new Date(run.startedAt)} />
) : (
""
)}
</div>
</TableCell>
<TableCell to={path} actionClassName="pl-0 tabular-nums">
<div className="flex items-center gap-1">
<CpuChipIcon className="size-4 text-success" />
{run.usageDurationMs > 0
? formatDurationMilliseconds(run.usageDurationMs, {
style: "short",
})
: ""}
</div>
</TableCell>
{showCompute && (
<TableCell to={path} className="tabular-nums">
{run.costInCents > 0
? formatCurrencyAccurate((run.costInCents + run.baseCostInCents) / 100)
: ""}
</TableCell>
)}
<TableCell to={path}>
<MachineLabelCombo preset={run.machinePreset} />
</TableCell>
<TableCell to={path}>
{run.queue.type === "task" ? (
<SimpleTooltip
buttonClassName="w-fit"
button={
<span className="flex items-center gap-1">
<TasksIcon className="size-[1.125rem] text-blue-500" />
<span>{run.queue.name}</span>
</span>
}
content={`This queue was automatically created from your "${run.queue.name}" task`}
disableHoverableContent
/>
) : (
<SimpleTooltip
buttonClassName="w-fit"
button={
<span className="flex items-center gap-1">
<RectangleStackIcon className="size-[1.125rem] text-purple-500" />
<span>{run.queue.name}</span>
</span>
}
content={`This is a custom queue you added in your code.`}
disableHoverableContent
/>
)}
</TableCell>
{showRegion && (
<TableCell to={path}>
{run.region ? (
<RegionLabel
region={regionByMasterQueue.get(run.region) ?? { name: run.region }}
iconClassName="size-4"
/>
) : (
""
)}
</TableCell>
)}
<TableCell to={path}>
{run.isTest ? (
<CheckIcon className="size-4 text-text-dimmed group-hover/table-row:text-text-bright" />
) : (
""
)}
</TableCell>
<TableCell to={path}>
{run.createdAt ? <DateTime date={run.createdAt} /> : ""}
</TableCell>
<TableCell to={path}>
{run.delayUntil ? <DateTime date={run.delayUntil} /> : ""}
</TableCell>
<TableCell to={path}>{run.ttl ?? ""}</TableCell>
<TableCell to={path} actionClassName="py-1" className="pr-16">
<div className="flex gap-1">
{run.tags.map((tag) => <RunTag key={tag} tag={tag} />) || ""}
</div>
</TableCell>
<RunActionsCell
run={run}
path={path}
canCancelRuns={canCancelRuns}
canReplayRuns={canReplayRuns}
/>
</TableRow>
);
})
)}
{isLoading && (
<TableBlankRow
colSpan={showRegion ? 16 : 15}
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-background-dimmed"
>
<Spinner /> <span className="text-text-dimmed">Loading</span>
</TableBlankRow>
)}
</TableBody>
</Table>
);
}
function RunActionsCell({
run,
path,
canCancelRuns,
canReplayRuns,
}: {
run: NextRunListItem;
path: string;
canCancelRuns: boolean;
canReplayRuns: boolean;
}) {
const location = useLocation();
if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;
return (
<TableCellMenu
isSticky
popoverContent={
<>
<PopoverMenuItem
to={path}
icon={ArrowRightIcon}
leadingIconClassName="text-blue-500"
title="View run"
/>
{run.isCancellable &&
(canCancelRuns ? (
<Dialog>
<DialogTrigger
asChild
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-background-raised hover:text-text-bright"
>
<Button
variant="small-menu-item"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
) : (
<Button
variant="small-menu-item"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
disabled
tooltip="You don't have permission to cancel runs"
>
Cancel run
</Button>
))}
{run.isReplayable &&
(canReplayRuns ? (
<Dialog>
<DialogTrigger
asChild
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-background-raised hover:text-text-bright"
>
<Button
variant="small-menu-item"
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-success"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
>
Replay run
</Button>
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={run.friendlyId}
failedRedirect={`${location.pathname}${location.search}`}
/>
</Dialog>
) : (
<Button
variant="small-menu-item"
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-success"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
disabled
tooltip="You don't have permission to replay runs"
>
Replay run
</Button>
))}
</>
}
hiddenButtons={
<>
{run.isCancellable && canCancelRuns && (
<SimpleTooltip
button={
<Dialog>
<DialogTrigger
asChild
className="size-6 rounded-sm p-1 text-text-bright transition hover:bg-background-raised"
>
<NoSymbolIcon className="size-3" />
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
}
content="Cancel run"
side="left"
disableHoverableContent
/>
)}
{run.isCancellable && canCancelRuns && run.isReplayable && canReplayRuns && (
<div className="mx-0.5 h-6 w-px bg-grid-dimmed" />
)}
{run.isReplayable && canReplayRuns && (
<SimpleTooltip
button={
<Dialog>
<DialogTrigger
asChild
className="h-6 w-6 rounded-sm p-1 text-text-bright transition hover:bg-background-raised"
>
<ArrowPathIcon className="size-3" />
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={run.friendlyId}
failedRedirect={`${location.pathname}${location.search}`}
/>
</Dialog>
}
content="Replay run…"
side="left"
disableHoverableContent
/>
)}
</>
}
/>
);
}
function NoRuns({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
<Paragraph className="w-auto">{title}</Paragraph>
</div>
);
}
function BlankState({
isLoading,
filters,
showRegion,
}: Pick<RunsTableProps, "isLoading" | "filters"> & { showRegion: boolean }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const colSpan = showRegion ? 16 : 15;
if (isLoading) return <TableBlankRow colSpan={colSpan}></TableBlankRow>;
const { tasks, from, to, ...otherFilters } = filters;
const singleTaskFromFilters = filters.tasks.length === 1 ? filters.tasks[0] : null;
const testPath = singleTaskFromFilters
? v3TestTaskPath(organization, project, environment, { taskIdentifier: singleTaskFromFilters })
: v3TestPath(organization, project, environment);
if (
filters.tasks.length === 1 &&
filters.from === undefined &&
filters.to === undefined &&
Object.values(otherFilters).every((filterArray) => filterArray.length === 0)
) {
return (
<TableBlankRow colSpan={colSpan}>
<Paragraph className="w-auto" variant="base/bright" spacing>
There are no runs for {filters.tasks[0]}
</Paragraph>
</TableBlankRow>
);
}
return (
<TableBlankRow colSpan={colSpan}>
<div className="flex flex-col items-center justify-center gap-6">
<Paragraph className="w-auto" variant="base/bright">
No runs match your filters. Try refreshing, modifying your filters or run a test.
</Paragraph>
<div className="flex items-center gap-2">
<Button
LeadingIcon={ArrowPathIcon}
variant="secondary/medium"
onClick={() => {
window.location.reload();
}}
>
Refresh
</Button>
<Paragraph>or</Paragraph>
<LinkButton
LeadingIcon={BeakerIcon}
leadingIconClassName="text-tests"
variant="secondary/medium"
to={testPath}
>
Run a test
</LinkButton>
</div>
</div>
</TableBlankRow>
);
}
@@ -0,0 +1,39 @@
import type { TaskTriggerSource } from "@trigger.dev/database";
import { ClockIcon } from "~/assets/icons/ClockIcon";
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
import { cn } from "~/utils/cn";
export function TaskTriggerSourceIcon({
source,
className,
}: {
source: TaskTriggerSource;
className?: string;
}) {
switch (source) {
case "STANDARD": {
return <TaskIconSmall className={cn("size-4.5 min-w-4.5 text-tasks", className)} />;
}
case "SCHEDULED": {
return <ClockIcon className={cn("size-4.5 min-w-4.5 text-schedules", className)} />;
}
case "AGENT": {
return <CubeSparkleIcon className={cn("size-4.5 min-w-4.5 text-agents", className)} />;
}
}
}
export function taskTriggerSourceDescription(source: TaskTriggerSource) {
switch (source) {
case "STANDARD": {
return "Standard task";
}
case "SCHEDULED": {
return "Scheduled task";
}
case "AGENT": {
return "Agent task";
}
}
}
@@ -0,0 +1,139 @@
import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { TextLink } from "~/components/primitives/TextLink";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { type WaitpointDetail } from "~/presenters/v3/WaitpointPresenter.server";
import { ForceTimeout } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route";
import { v3WaitpointTokenPath, v3WaitpointTokensPath } from "~/utils/pathBuilder";
import { PacketDisplay } from "./PacketDisplay";
import { WaitpointStatusCombo } from "./WaitpointStatus";
import { RunTag } from "./RunTag";
import { ClipboardField } from "~/components/primitives/ClipboardField";
export function WaitpointDetailTable({
waitpoint,
linkToList = false,
}: {
waitpoint: WaitpointDetail;
linkToList?: boolean;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const hasExpired =
waitpoint.idempotencyKeyExpiresAt && waitpoint.idempotencyKeyExpiresAt < new Date();
return (
<Property.Table>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<WaitpointStatusCombo status={waitpoint.status} className="text-sm" />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value className="whitespace-pre-wrap">
{linkToList ? (
<TextLink
to={v3WaitpointTokenPath(organization, project, environment, waitpoint, {
id: waitpoint.id,
})}
>
{waitpoint.id}
</TextLink>
) : (
waitpoint.id
)}
</Property.Value>
</Property.Item>
{waitpoint.type === "MANUAL" && (
<Property.Item>
<Property.Label>Callback URL</Property.Label>
<Property.Value className="my-1">
<ClipboardField value={waitpoint.url} variant={"secondary/small"} />
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Idempotency key</Property.Label>
<Property.Value>
<div>
<div>
{waitpoint.userProvidedIdempotencyKey
? (waitpoint.inactiveIdempotencyKey ?? waitpoint.idempotencyKey)
: ""}
</div>
<div>
{waitpoint.idempotencyKeyExpiresAt ? (
<>
{hasExpired ? "Expired" : "Expires at"}:{" "}
<DateTime date={waitpoint.idempotencyKeyExpiresAt} />
</>
) : null}
</div>
</div>
</Property.Value>
</Property.Item>
{waitpoint.type === "MANUAL" && (
<>
<Property.Item>
<Property.Label>Timeout</Property.Label>
<Property.Value>
<div>
<div className="flex w-full flex-wrap items-center justify-between gap-1">
{waitpoint.completedAfter ? (
<>
<DateTimeAccurate date={waitpoint.completedAfter} />
</>
) : (
""
)}
{waitpoint.status === "WAITING" && <ForceTimeout waitpoint={waitpoint} />}
</div>
<Paragraph variant="extra-small" className="text-text-dimmed/70">
{waitpoint.status === "TIMED_OUT"
? "The waitpoint timed out"
: waitpoint.status === "COMPLETED"
? "The waitpoint completed before this timeout was reached"
: "The waitpoint is still waiting"}
</Paragraph>
</div>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Tags</Property.Label>
<Property.Value>
<div className="flex flex-wrap gap-1 pt-1 text-xs">
{waitpoint.tags.map((tag) => (
<RunTag
key={tag}
tag={tag}
to={v3WaitpointTokensPath(organization, project, environment, { tags: [tag] })}
/>
))}
</div>
</Property.Value>
</Property.Item>
</>
)}
<Property.Item>
<Property.Label>Completed</Property.Label>
<Property.Value>
{waitpoint.completedAt ? <DateTimeAccurate date={waitpoint.completedAt} /> : ""}
</Property.Value>
</Property.Item>
{waitpoint.status === "WAITING" ? null : waitpoint.status === "TIMED_OUT" ? (
<></>
) : waitpoint.output ? (
<PacketDisplay title="Output" data={waitpoint.output} dataType={waitpoint.outputType} />
) : waitpoint.completedAfter ? null : (
"Completed with no output"
)}
</Property.Table>
);
}
@@ -0,0 +1,79 @@
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { type WaitpointTokenStatus } from "@trigger.dev/core/v3";
import assertNever from "assert-never";
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export function WaitpointStatusCombo({
status,
className,
iconClassName,
}: {
status: WaitpointTokenStatus;
className?: string;
iconClassName?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<WaitpointStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
<WaitpointStatusLabel status={status} />
</span>
);
}
export function WaitpointStatusLabel({ status }: { status: WaitpointTokenStatus }) {
return (
<span className={waitpointStatusClassNameColor(status)}>{waitpointStatusTitle(status)}</span>
);
}
export function WaitpointStatusIcon({
status,
className,
}: {
status: WaitpointTokenStatus;
className: string;
}) {
switch (status) {
case "WAITING":
return <Spinner className={cn(waitpointStatusClassNameColor(status), className)} />;
case "TIMED_OUT":
return <TimedOutIcon className={cn(waitpointStatusClassNameColor(status), className)} />;
case "COMPLETED":
return <CheckCircleIcon className={cn(waitpointStatusClassNameColor(status), className)} />;
default: {
assertNever(status);
}
}
}
export function waitpointStatusClassNameColor(status: WaitpointTokenStatus): string {
switch (status) {
case "WAITING":
return "text-blue-500";
case "TIMED_OUT":
return "text-error";
case "COMPLETED": {
return "text-success";
}
default: {
assertNever(status);
}
}
}
export function waitpointStatusTitle(status: WaitpointTokenStatus): string {
switch (status) {
case "WAITING":
return "Waiting";
case "TIMED_OUT":
return "Timed out";
case "COMPLETED": {
return "Completed";
}
default: {
assertNever(status);
}
}
}
@@ -0,0 +1,567 @@
import * as Ariakit from "@ariakit/react";
import { FingerPrintIcon, TagIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { Form, useFetcher } from "@remix-run/react";
import { WaitpointTokenStatus, waitpointTokenStatuses } from "@trigger.dev/core/v3";
import { ListChecks } from "lucide-react";
import { matchSorter } from "match-sorter";
import { type ReactNode, useEffect, useMemo, useRef } from "react";
import { z } from "zod";
import { StatusIcon } from "~/assets/icons/StatusIcon";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Button } from "~/components/primitives/Buttons";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
shortcutFromIndex,
} from "~/components/primitives/Select";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { Spinner } from "~/components/primitives/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type loader as tagsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags";
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
import {
appliedSummary,
FilterMenuProvider,
IdFilterDropdown,
type IdFilterDropdownProps,
TimeFilter,
} from "./SharedFilters";
import { WaitpointStatusCombo, waitpointStatusTitle } from "./WaitpointStatus";
export const WaitpointSearchParamsSchema = z.object({
id: z.string().optional(),
statuses: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
WaitpointTokenStatus.array().optional()
),
idempotencyKey: z.string().optional(),
tags: z.string().array().optional(),
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
cursor: z.string().optional(),
direction: z.enum(["forward", "backward"]).optional(),
});
export type WaitpointSearchParams = z.infer<typeof WaitpointSearchParamsSchema>;
type WaitpointTokenFiltersProps = {
hasFilters: boolean;
};
export function WaitpointTokenFilters(props: WaitpointTokenFiltersProps) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("statuses") ||
searchParams.has("tags") ||
searchParams.has("id") ||
searchParams.has("idempotencyKey") ||
searchParams.has("period") ||
searchParams.has("from") ||
searchParams.has("to");
return (
<div className="flex flex-row flex-wrap items-center gap-1.5">
<PermanentStatusFilter />
<PermanentTagsFilter />
<PermanentWaitpointIdFilter />
<PermanentIdempotencyKeyFilter />
<TimeFilter shortcut={{ key: "d" }} />
{hasFilters && (
<Form className="-ml-1 h-6">
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</div>
);
}
const statuses = waitpointTokenStatuses.map((status) => ({
title: waitpointStatusTitle(status),
value: status,
}));
function StatusDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ statuses: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue]);
return (
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<SelectList>
{filtered.map((item, index) => {
return (
<SelectItem
key={item.value}
value={item.value}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="group flex w-full flex-col py-0">
<WaitpointStatusCombo status={item.value} iconClassName="animate-none" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={50}>
<Paragraph variant="extra-small">
{waitpointStatusTitle(item.value)}
</Paragraph>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SelectItem>
);
})}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const statusShortcut = { key: "s" };
function PermanentStatusFilter() {
const { values, del } = useSearchParams();
const selectedStatuses = values("statuses");
const hasStatuses = selectedStatuses.length > 0 && !selectedStatuses.every((v) => v === "");
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: statusShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasStatuses ? (
<AppliedFilter
label="Status"
icon={<StatusIcon className="size-3.5" />}
value={appliedSummary(
selectedStatuses.map((v) => waitpointStatusTitle(v as WaitpointTokenStatus))
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1 rounded border border-border-bright bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-border-brighter group-hover:bg-surface-control">
<div className="grid size-4 place-items-center">
<div className="size-[75%] rounded-full border-2 border-text-bright" />
</div>
<span>Status</span>
</div>
)}
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by status</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={statusShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function TagsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({
tags: values,
cursor: undefined,
direction: undefined,
});
};
const fetcher = useFetcher<typeof tagsLoader>();
useEffect(() => {
const searchParams = new URLSearchParams();
if (searchValue) {
searchParams.set("name", searchValue);
}
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/tags?${searchParams}`
);
}, [searchValue]);
const filtered = useMemo(() => {
let items: string[] = [];
if (searchValue === "") {
items = values("tags");
}
if (fetcher.data === undefined) {
return matchSorter(items, searchValue);
}
items.push(...fetcher.data.tags.map((t) => t.name));
return matchSorter(Array.from(new Set(items)), searchValue);
}, [searchValue, fetcher.data]);
return (
<SelectProvider value={values("tags")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
{!(filtered.length === 0 && fetcher.state !== "loading" && searchValue === "") && (
<ComboBox
value={searchValue}
render={(props) => (
<div className="flex items-center justify-stretch">
<input {...props} placeholder={"Filter by tags..."} />
{fetcher.state === "loading" && <Spinner color="muted" />}
</div>
)}
/>
)}
<SelectList>
{filtered.length > 0
? filtered.map((tag) => (
<SelectItem key={tag} value={tag} className="text-text-bright">
{tag}
</SelectItem>
))
: null}
{filtered.length === 0 && fetcher.state !== "loading" && (
<SelectItem disabled>No tags found</SelectItem>
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const tagsShortcut = { key: "g" };
function PermanentTagsFilter() {
const { values, del } = useSearchParams();
const tags = values("tags");
const hasTags = tags.length > 0 && !tags.every((v) => v === "");
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: tagsShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TagsDropdown
trigger={
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasTags ? (
<AppliedFilter
label="Tags"
icon={<TagIcon className="size-3.5" />}
value={appliedSummary(tags)}
onRemove={() => del(["tags", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1.5 rounded border border-border-bright bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-border-brighter group-hover:bg-surface-control">
<TagIcon className="size-4" />
<span>Tags</span>
</div>
)}
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by tags</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={tagsShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
const validateWaitpointId = makeFriendlyIdValidator("waitpoint", "Waitpoint");
function WaitpointIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
) {
return (
<IdFilterDropdown
{...props}
label="Waitpoint ID"
placeholder="waitpoint_"
paramKey="id"
validate={validateWaitpointId}
/>
);
}
const waitpointIdShortcut = { key: "w" };
function PermanentWaitpointIdFilter() {
const { value, del } = useSearchParams();
const id = value("id");
const hasId = id !== undefined;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: waitpointIdShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<WaitpointIdDropdown
trigger={
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasId ? (
<AppliedFilter
label="ID"
icon={<FingerPrintIcon className="size-3.5" />}
value={id}
onRemove={() => del(["id", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1.5 rounded border border-border-bright bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-border-brighter group-hover:bg-surface-control">
<FingerPrintIcon className="size-4" />
<span>Waitpoint ID</span>
</div>
)}
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by waitpoint ID</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={waitpointIdShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function IdempotencyKeyDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
) {
return (
<IdFilterDropdown
{...props}
label="Idempotency key"
placeholder=""
paramKey="idempotencyKey"
validate={(v) => {
if (v.length === 0) return "Idempotency keys need to be at least 1 character in length";
return undefined;
}}
/>
);
}
const idempotencyKeyShortcut = { key: "i" };
function PermanentIdempotencyKeyFilter() {
const { value, del } = useSearchParams();
const idempotencyKey = value("idempotencyKey");
const hasKey = idempotencyKey !== undefined;
const triggerRef = useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: idempotencyKeyShortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
triggerRef.current?.click();
},
});
return (
<FilterMenuProvider>
{(search, setSearch) => (
<IdempotencyKeyDropdown
trigger={
<Ariakit.TooltipProvider timeout={200} hideTimeout={0}>
<Ariakit.TooltipAnchor
render={
<Ariakit.Select
ref={triggerRef as any}
render={<div className="group cursor-pointer focus-custom" />}
/>
}
>
{hasKey ? (
<AppliedFilter
label="Idempotency key"
icon={<ListChecks className="size-3.5" />}
value={idempotencyKey}
onRemove={() => del(["idempotencyKey", "cursor", "direction"])}
variant="secondary/small"
className="pl-1"
/>
) : (
<div className="flex h-6 items-center gap-1.5 rounded border border-border-bright bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-border-brighter group-hover:bg-surface-control">
<ListChecks className="size-4" />
<span>Idempotency key</span>
</div>
)}
</Ariakit.TooltipAnchor>
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs">
<div className="flex items-center gap-2">
<span>Filter by idempotency key</span>
<ShortcutKey
className="size-4 flex-none"
shortcut={idempotencyKeyShortcut}
variant="small"
/>
</div>
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -0,0 +1,327 @@
import type { UIMessage } from "@ai-sdk/react";
import { memo } from "react";
import { AssistantResponse, ChatBubble, ToolUseRow } from "~/components/runs/v3/ai/AIChatMessages";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
// ---------------------------------------------------------------------------
// AgentMessageView — renders an AI SDK UIMessage[] conversation.
//
// Extracted from the playground route so it can be reused on the run details
// page when the user picks the Agent view.
//
// UIMessage part types (AI SDK):
// text — markdown text content
// reasoning — model reasoning/thinking
// tool-{name} — tool call with input/output/state
// source-url — citation link
// source-document — citation document reference
// file — file attachment (image, etc.)
// step-start — visual separator between steps
// data-{name} — custom data parts (rendered as a small popover)
// ---------------------------------------------------------------------------
export function AgentMessageView({ messages }: { messages: UIMessage[] }) {
return (
<div className="mx-auto flex w-full min-w-0 max-w-[800px] flex-col gap-2">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
</div>
);
}
// Memoized so stable messages (anything older than the one currently
// streaming) don't re-render on every chunk. This matters a lot during
// `resumeStream()` history replay, where each re-render would otherwise
// re-run Prism highlighting on every tool-call CodeBlock in the list.
//
// Default shallow prop comparison is fine: AI SDK's useChat keeps stable
// references for messages that haven't changed, so only the last message
// (the one receiving new chunks) re-renders.
export const MessageBubble = memo(function MessageBubble({ message }: { message: UIMessage }) {
if (message.role === "user") {
const text =
message.parts
?.filter((p) => p.type === "text")
.map((p) => (p as { type: "text"; text: string }).text)
.join("") ?? "";
return (
<div className="flex min-w-0 justify-end">
<div className="max-w-[80%] rounded-lg bg-indigo-600 px-4 py-2.5 text-sm text-white">
<div className="whitespace-pre-wrap wrap-anywhere">{text}</div>
</div>
</div>
);
}
if (message.role === "assistant") {
const hasContent = message.parts && message.parts.length > 0;
if (!hasContent) return null;
return <div className="space-y-2">{renderAssistantParts(message.parts ?? [])}</div>;
}
return null;
});
// Group consecutive data-* parts (rendered as inline DataPartPopover pills)
// under a single "Tool calls:" label with a flex-wrap row so they have a
// proper gap between them. Non-data parts pass through to renderPart
// unchanged.
function renderAssistantParts(parts: UIMessage["parts"]) {
const nodes: React.ReactNode[] = [];
let i = 0;
while (i < parts.length) {
const type = parts[i].type as string;
if (type?.startsWith?.("data-") && type !== "data-subagent-run") {
const groupStart = i;
const group: UIMessage["parts"] = [];
while (
i < parts.length &&
(parts[i].type as string)?.startsWith?.("data-") &&
(parts[i].type as string) !== "data-subagent-run"
) {
group.push(parts[i]);
i++;
}
nodes.push(
<div key={`data-${groupStart}`} className="flex items-center gap-1.5">
<span className="text-xs font-medium text-text-dimmed">AI SDK data parts:</span>
<div className="flex flex-wrap gap-1.5">
{group.map((g, k) => renderPart(g, groupStart + k))}
</div>
</div>
);
} else {
nodes.push(renderPart(parts[i], i));
i++;
}
}
return nodes;
}
// URLs in `source-url`/`file` parts come from streamed agent/tool data, so an
// unsafe scheme like `javascript:` would become a clickable XSS payload once it
// reaches an href/src. Allow only http(s)/blob (and data: for inline images),
// and return null for anything else so the caller can skip the link/image.
export function toSafeUrl(value: unknown, allowDataImage = false): string | null {
if (typeof value !== "string") return null;
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return null;
}
if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "blob:") {
return value;
}
if (allowDataImage && parsed.protocol === "data:" && /^data:image\//i.test(value)) {
return value;
}
return null;
}
export function renderPart(part: UIMessage["parts"][number], i: number) {
const p = part as any;
const type = part.type as string;
// Text — markdown rendered via AssistantResponse
if (type === "text") {
return p.text ? <AssistantResponse key={i} text={p.text} headerLabel="" /> : null;
}
// Reasoning — amber-bordered italic block
if (type === "reasoning") {
return (
<div key={i} className="border-l-2 border-amber-500/40 pl-2">
<ChatBubble>
<div className="whitespace-pre-wrap text-xs italic text-amber-200/70">{p.text ?? ""}</div>
</ChatBubble>
</div>
);
}
// Tool call — type: "tool-{name}" with toolCallId, input, output, state
if (type.startsWith("tool-")) {
const toolName = type.slice(5);
// Sub-agent tool: output is a UIMessage with parts
const isSubAgent =
p.output != null && typeof p.output === "object" && Array.isArray(p.output.parts);
// For sub-agent tools, show the last text part as the "output" tab
// (mirrors what toModelOutput typically sends to the parent LLM)
// instead of dumping the full UIMessage JSON.
let resultOutput: string | undefined;
if (isSubAgent) {
const lastText = (p.output.parts as any[])
.filter((part: any) => part.type === "text" && part.text)
.pop();
resultOutput = lastText?.text ?? undefined;
} else if (p.output != null) {
resultOutput = typeof p.output === "string" ? p.output : JSON.stringify(p.output, null, 2);
}
// Status label for the tool row. AI SDK 7 HITL adds the
// approval-requested / approval-responded states between input-available
// and output-available, so surface those alongside the existing states.
let resultSummary: string | undefined;
if (p.state === "input-streaming" || p.state === "input-available") {
resultSummary = "calling...";
} else if (p.state === "approval-requested") {
resultSummary = "awaiting approval";
} else if (p.state === "approval-responded" || p.state === "output-denied") {
resultSummary = p.approval?.approved
? "approved"
: `denied${p.approval?.reason ? `: ${p.approval.reason}` : ""}`;
} else if (p.state === "output-error") {
resultSummary = `error: ${p.errorText ?? "unknown"}`;
}
return (
<ToolUseRow
key={i}
tool={{
toolCallId: p.toolCallId ?? `tool-${i}`,
toolName,
inputJson: JSON.stringify(p.input ?? {}, null, 2),
resultOutput,
resultSummary,
subAgent: isSubAgent
? {
parts: p.output.parts,
isStreaming: p.state === "output-available" && p.preliminary === true,
}
: undefined,
}}
/>
);
}
// Source URL — clickable citation link
if (type === "source-url") {
const safeUrl = toSafeUrl(p.url);
const label = p.title || p.url;
// Unsafe scheme: render the citation text without a clickable link.
if (!safeUrl) {
return label ? (
<div key={i} className="text-xs text-text-dimmed">
{label}
</div>
) : null;
}
return (
<div key={i} className="text-xs">
<a
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 underline hover:text-indigo-300"
>
{label}
</a>
</div>
);
}
// Source document — citation label
if (type === "source-document") {
return (
<div key={i} className="text-xs text-text-dimmed">
{p.title}
{p.mediaType ? ` (${p.mediaType})` : ""}
</div>
);
}
// File — render as image if image type, otherwise as download link
if (type === "file") {
const isImage = typeof p.mediaType === "string" && p.mediaType.startsWith("image/");
if (isImage) {
const safeSrc = toSafeUrl(p.url, true); // allow data: URIs for inline images
// Unsafe scheme: fall back to the filename, matching the non-image branch.
if (!safeSrc) {
return p.filename ? (
<div key={i} className="text-xs text-text-dimmed">
{p.filename}
</div>
) : null;
}
return (
<img
key={i}
src={safeSrc}
alt={p.filename ?? "file"}
className="max-h-64 rounded border border-border-bright"
/>
);
}
const safeUrl = toSafeUrl(p.url);
// Unsafe scheme: show the filename without a clickable download link.
if (!safeUrl) {
return p.filename ? (
<div key={i} className="text-xs text-text-dimmed">
{p.filename}
</div>
) : null;
}
return (
<div key={i} className="text-xs">
<a
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 underline hover:text-indigo-300"
>
{p.filename ?? "Download file"}
</a>
</div>
);
}
// Step start — subtle dashed separator with centered label
if (type === "step-start") {
return (
<div key={i} className="flex items-center gap-2 py-0.5">
<div className="flex-1 border-t border-dashed border-border-bright" />
<span className="text-[10px] text-text-faint">step</span>
<div className="flex-1 border-t border-dashed border-border-bright" />
</div>
);
}
// Data parts — type: "data-{name}", show as labeled JSON popover
if (type.startsWith("data-")) {
const dataName = type.slice(5);
return <DataPartPopover key={i} name={dataName} data={p.data} />;
}
return null;
}
function DataPartPopover({ name, data }: { name: string; data: unknown }) {
const formatted = JSON.stringify(data, null, 2);
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-border-bright bg-background-bright px-1.5 py-0.5 font-mono text-[10px] text-text-dimmed transition-colors hover:border-border-brightest hover:text-text-bright"
>
<span className="text-purple-400">{name}</span>
<span className="text-text-faint">{"{}"}</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-auto max-w-md p-0" align="start" sideOffset={4}>
<div className="flex items-center justify-between border-b border-border-bright px-2.5 py-1.5">
<span className="text-[10px] font-medium text-text-dimmed">data-{name}</span>
</div>
<div className="max-h-60 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<pre className="p-2.5 text-[11px] leading-relaxed text-text-bright">{formatted}</pre>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,965 @@
import type { UIMessage } from "@ai-sdk/react";
import { ChatSnapshotV1Schema, SSEStreamSubscription } from "@trigger.dev/core/v3";
import { useEffect, useMemo, useRef, useState } from "react";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { AgentMessageView } from "~/components/runs/v3/agent/AgentMessageView";
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
export type AgentViewAuth = {
publicAccessToken: string;
apiOrigin: string;
/**
* Session identifier the AgentView uses to address the backing
* {@link Session} when subscribing to `.in` / `.out`. Accepts either
* a `session_*` friendlyId or the transport-supplied externalId
* (typically the browser's `chatId`) — the dashboard resource route
* resolves either form via `resolveSessionByIdOrExternalId`.
*/
sessionId: string;
/**
* User messages extracted from the run's task payload at load time.
* Empty array for runs started with `trigger: "preload"` — in that
* case the first user message arrives over the session's `.in`
* channel and is merged in by the AgentView subscription.
*/
initialMessages: UIMessage[];
/**
* Presigned GET URL for the session's chat-snapshot S3 blob (written
* by the agent after each turn-complete; see `ChatSnapshotV1`).
* Optional — sessions that registered a `hydrateMessages` hook skip
* snapshot writes and the URL fetch will 404. In that case the
* dashboard falls back to seq=0 SSE (which, post-trim, shows only the
* most recent turn). Generated server-side by `SessionPresenter`.
*/
snapshotPresignedUrl?: string;
};
/**
* Max state-update interval while assistant chunks are streaming. Matches
* the `experimental_throttle: 100` we previously passed to `useChat`.
* Chunks mutate a staging ref synchronously; a throttled flush copies the
* ref into React state at most ~10x/sec so tool-call Prism highlighting
* etc. doesn't re-run on every single text-delta.
*/
const STATE_FLUSH_THROTTLE_MS = 100;
/**
* Sentinel timestamp for messages that came from the run's initial task
* payload — they predate any stream activity, so 0 guarantees they sort
* first regardless of stream race order.
*/
const INITIAL_PAYLOAD_TIMESTAMP = 0;
/**
* Renders a Session's chat conversation as it unfolds.
*
* Subscribes to both channels of the {@link Session}:
* - **`.out`** delivers assistant `UIMessageChunk`s (text deltas, tool
* calls, reasoning, etc.) produced by the agent's
* `chatStream.writer(...)` calls — objects, already parsed by the S2
* SSE reader.
* - **`.in`** delivers {@link ChatInputChunk}s sent by
* {@link TriggerChatTransport} (or any other session writer). Each
* chunk is a tagged union (`{kind: "message", payload}` for user
* turns, `{kind: "stop"}` for stop signals) — the AgentView only
* cares about `kind: "message"` and pulls `.payload.messages`.
*
* Both streams are read directly via `SSEStreamSubscription` through the
* dashboard's session-authed resource routes — not through `useChat` or
* `TriggerChatTransport`. This gives us per-chunk server-side timestamps
* (S2 sequence numbers) from both streams, which we use to produce a
* chronologically correct merged message list that works for replays,
* multi-message turns, cross-run session resumes, and steering messages.
*
* Intended to be mounted inside a scrollable container — the component
* does not own its own scrollbar.
*/
export function AgentView({ agentView }: { agentView: AgentViewAuth }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const messages = useAgentSessionMessages({
sessionId: agentView.sessionId,
apiOrigin: agentView.apiOrigin,
orgSlug: organization.slug,
projectSlug: project.slug,
envSlug: environment.slug,
initialMessages: agentView.initialMessages,
snapshotPresignedUrl: agentView.snapshotPresignedUrl,
});
// Sticky-bottom auto-scroll: walks up to find the inspector's scroll
// container, then scrolls to bottom whenever `messages` changes — but
// only if the user was at (or near) the bottom at the time. Scrolling
// away pauses auto-scroll; scrolling back resumes it.
const rootRef = useAutoScrollToBottom([messages]);
return (
<div ref={rootRef} className="flex min-h-full flex-col py-3">
{messages.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Spinner className="size-5" color="blue" />
<Paragraph variant="small" className="text-text-dimmed">
Loading conversation
</Paragraph>
</div>
</div>
) : (
<AgentMessageView messages={messages} />
)}
</div>
);
}
// ---------------------------------------------------------------------------
// useAgentSessionMessages — reads both realtime streams for a session and
// maintains a chronologically ordered, merged message list.
// ---------------------------------------------------------------------------
/**
* Shape of each chunk on the session's `.in` channel. Mirrors the
* `ChatInputChunk` tagged union produced by {@link TriggerChatTransport}:
* - `kind: "message"` carries a `ChatTaskWirePayload` in `.payload`
* (user-submitted messages or regenerate calls); we dedupe by id.
* - `kind: "stop"` is a stop signal — no messages, nothing to render
* here, so it's filtered.
*
* Wire payloads are slim-wire (one new UIMessage per record, on
* `payload.message`). The legacy `payload.messages` array shape is kept
* here as a fallback so any historical records on a long-lived session
* still render.
*
* The server wraps records in `{data, id}` and writes `data` as a JSON
* string; SSE v2 delivers the parsed string back. {@link parseChunkPayload}
* re-parses to recover the object.
*/
type InputStreamChunk = {
kind?: "message" | "stop";
payload?: {
message?: { id?: string; role?: string; parts?: unknown[] };
messages?: Array<{ id?: string; role?: string; parts?: unknown[] }>;
trigger?: string;
};
message?: string;
};
/**
* Minimal typing for the chunks we care about on the chat output stream.
* Covers the AI SDK `UIMessageChunk` variants that `renderPart` actually
* knows how to display, plus the Trigger.dev control chunks that we filter.
*/
type OutputChunk = { type: string; [key: string]: unknown };
/**
* Per-message orchestration state for the output stream accumulator. Mirrors
* the active-part tracking that AI SDK's `processUIMessageStream` keeps in
* its `state` object: a registry of streaming text/reasoning parts so deltas
* can be matched to the right part by id, plus a way to clear them at step
* boundaries (`finish-step`) so the next step's `text-start`/`reasoning-start`
* with the same id starts a fresh part instead of appending to the previous
* step's part.
*/
/**
* Per-message orchestration state — index-based active-part tracking.
*
* Each map points from a part id (text or reasoning) to **the index of the
* currently-streaming part with that id in `message.parts`**. We need
* indexes (not just a `Set` of "active ids") because part ids are *only
* unique within a step*: the SDK happily reuses `text-start id="0"` after
* a `finish-step` boundary. Without index tracking, a `text-delta` for the
* reused id would have to find the right part by id alone — and a search
* would match BOTH the previous step's frozen part and the current step's
* fresh one, which produces a duplication where the previous text gets
* the new content appended to it AND a fresh part with the same content
* also appears.
*
* Mirrors AI SDK's `processUIMessageStream`'s `state.activeTextParts` /
* `state.activeReasoningParts` (which hold direct references in the
* mutating canonical impl). We use indexes here because we do immutable
* updates and need indices that survive `parts.map()` rewrites — adding
* new parts and updating existing ones never reorders, so an index is
* stable for the lifetime of the part.
*/
type MessageOrchestrationState = {
activeTextPartIndexes: Map<string, number>;
activeReasoningPartIndexes: Map<string, number>;
};
/**
* `SSEStreamSubscription`'s v2 batch path delivers `parsedBody.data` as-is
* — but session channels diverge by direction:
*
* - `.in`: {@link TriggerChatTransport.serializeInputChunk} writes the
* `ChatInputChunk` as a JSON **string**, so `data` is a string that
* needs a second `JSON.parse` to recover the tagged union.
* - `.out`: the agent's `chatStream.writer(...)` writes
* {@link UIMessageChunk} **objects** directly; `data` arrives
* already-parsed.
*
* This helper accepts both shapes defensively: a string is parsed; an
* object is returned as-is. Returns `null` for unparseable payloads.
*/
function parseChunkPayload(raw: unknown): Record<string, unknown> | null {
if (raw == null) return null;
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
if (typeof raw === "object") return raw as Record<string, unknown>;
return null;
}
function createOrchestrationState(): MessageOrchestrationState {
return {
activeTextPartIndexes: new Map(),
activeReasoningPartIndexes: new Map(),
};
}
function useAgentSessionMessages({
sessionId,
apiOrigin,
orgSlug,
projectSlug,
envSlug,
initialMessages,
snapshotPresignedUrl,
}: {
sessionId: string;
apiOrigin: string;
orgSlug: string;
projectSlug: string;
envSlug: string;
initialMessages: UIMessage[];
snapshotPresignedUrl?: string;
}): UIMessage[] {
// Seed with the user messages from the run's task payload.
const seedMessages = useMemo(
() => initialMessages.filter((m) => m.role === "user"),
[initialMessages]
);
// The snapshot URL is re-signed by the loader on every navigation
// (tab switches in the inspector pane re-run the session loader),
// which would otherwise re-trigger the subscription effect below
// and replay post-snapshot `.out` chunks on top of the messages we
// already accumulated — duplicating any assistant content that
// lives past `snapshot.lastOutEventId` (e.g., a canceled run whose
// turn never completed). Hold the URL behind a ref and keep it
// out of the effect's deps so the effect runs exactly once per
// mount.
const snapshotUrlRef = useRef(snapshotPresignedUrl);
useEffect(() => {
snapshotUrlRef.current = snapshotPresignedUrl;
}, [snapshotPresignedUrl]);
// `pendingRef` is the authoritative, eagerly-updated message state:
// chunks mutate this synchronously as they arrive. A throttled flush
// copies it into React state so UI updates are capped at ~10x/sec.
const pendingRef = useRef<Map<string, UIMessage>>(new Map(seedMessages.map((m) => [m.id, m])));
const timestampsRef = useRef<Map<string, number>>(
new Map(seedMessages.map((m) => [m.id, INITIAL_PAYLOAD_TIMESTAMP]))
);
// Side-table of orchestration state, keyed by assistant message id. Lives
// outside the UIMessage so React doesn't see it as a renderable prop.
const orchestrationRef = useRef<Map<string, MessageOrchestrationState>>(new Map());
// Buffered HITL resolutions keyed by toolCallId. `addToolApprovalResponse` /
// `addToolOutput` send a slim assistant message on the `.in` channel carrying
// just the resolved tool part; the agent never echoes these on `.out`. We
// stash them here and overlay onto the matching tool part once it exists, so
// a denial/approval lands regardless of which stream arrives first.
const pendingResolutionsRef = useRef<Map<string, Record<string, unknown>>>(new Map());
// React state snapshot of pendingRef. Only updated via the throttled
// `scheduleFlush`. The Map *reference* changes on every flush so React
// detects the state update and the downstream `useMemo` recomputes.
const [messagesById, setMessagesById] = useState<Map<string, UIMessage>>(
() => new Map(pendingRef.current)
);
// Throttled flush scheduler — leading edge within a single throttle
// window: the first chunk after a quiet period flushes immediately, then
// subsequent chunks coalesce until the next window opens.
const lastFlushAtRef = useRef<number>(0);
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scheduleFlush = useRef<() => void>(() => {});
scheduleFlush.current = () => {
if (pendingTimerRef.current !== null) return; // already scheduled
const now = Date.now();
const sinceLast = now - lastFlushAtRef.current;
const delay = Math.max(0, STATE_FLUSH_THROTTLE_MS - sinceLast);
pendingTimerRef.current = setTimeout(() => {
pendingTimerRef.current = null;
lastFlushAtRef.current = Date.now();
setMessagesById(new Map(pendingRef.current));
}, delay);
};
useEffect(() => {
const abort = new AbortController();
// Overlay a buffered HITL resolution (approval/output delivered on `.in`)
// onto the matching tool part. Returns true if a part changed. Safe to call
// repeatedly — after each `.out` tool chunk and whenever a `.in` resolution
// arrives — so the resolution lands regardless of cross-stream ordering.
// Never downgrades a part that already reached a terminal output state (so
// an approved-then-executed tool keeps its `output-available` + output).
const applyToolResolution = (toolCallId: string): boolean => {
const res = pendingResolutionsRef.current.get(toolCallId);
if (!res) return false;
for (const [mid, msg] of pendingRef.current) {
const parts = (msg.parts ?? []) as Array<Record<string, unknown>>;
const idx = parts.findIndex(
(p) => (p as { toolCallId?: string }).toolCallId === toolCallId
);
if (idx < 0) continue;
const cur = parts[idx]!;
const terminal =
cur.state === "output-available" ||
cur.state === "output-error" ||
cur.state === "output-denied";
const nextState = res.state != null && !terminal ? res.state : cur.state;
const sameApproval = JSON.stringify(cur.approval) === JSON.stringify(res.approval);
if (
nextState === cur.state &&
sameApproval &&
res.output === undefined &&
res.errorText === undefined
) {
// Already applied. If the part has reached a terminal state, drop
// the buffered resolution so the Map doesn't grow unbounded on
// long-lived sessions with many tool calls.
if (terminal) pendingResolutionsRef.current.delete(toolCallId);
return false;
}
const next = parts.slice();
next[idx] = {
...cur,
...(res.approval != null ? { approval: res.approval } : {}),
...(res.output !== undefined ? { output: res.output } : {}),
...(res.errorText !== undefined ? { errorText: res.errorText } : {}),
state: nextState,
};
pendingRef.current.set(mid, { ...msg, parts: next } as UIMessage);
// Drop the buffered entry once the part has landed at a terminal
// state — no future `.out` chunk will need this resolution.
const reachedTerminal =
nextState === "output-available" ||
nextState === "output-error" ||
nextState === "output-denied";
if (reachedTerminal) pendingResolutionsRef.current.delete(toolCallId);
return true;
}
return false;
};
const encodedSession = encodeURIComponent(sessionId);
// Always use the page's own origin to avoid CORS preflight failures
// when the configured `apiOrigin` (e.g. `localhost`) differs from the
// origin the dashboard was loaded from (e.g. `127.0.0.1`). The dashboard
// resource route is same-origin by construction.
const origin = typeof window !== "undefined" ? window.location.origin : apiOrigin;
const sessionBase =
`${origin}/resources/orgs/${orgSlug}/projects/${projectSlug}/env/${envSlug}` +
`/sessions/${encodedSession}/realtime/v1`;
const outputUrl = `${sessionBase}/out`;
const inputUrl = `${sessionBase}/in`;
/**
* Try to seed `pendingRef` from the agent's S3 snapshot blob and return
* the snapshot's `lastOutEventId` so the `.out` SSE subscription resumes
* just past the snapshot. Returns undefined for sessions that don't
* have a snapshot (e.g. `hydrateMessages` customers, or sessions that
* have never completed a turn).
*/
const loadSnapshot = async (): Promise<string | undefined> => {
const url = snapshotUrlRef.current;
if (!url) return undefined;
try {
const resp = await fetch(url, { signal: abort.signal });
if (!resp.ok) return undefined;
const json = (await resp.json()) as unknown;
const parsed = ChatSnapshotV1Schema.safeParse(json);
if (!parsed.success) return undefined;
const snapshot = parsed.data;
// Preserve the snapshot's array order in the final render by
// giving each message a unique, monotonically increasing
// timestamp from `(savedAt - count + index)`. Real chunk
// timestamps from the SSE path use S2 arrival ms (positive
// numbers in the present), so anything below `savedAt` sorts
// before live chunks while preserving snapshot order among
// themselves.
const count = snapshot.messages.length;
snapshot.messages.forEach((raw, i) => {
const message = raw as UIMessage;
if (!message?.id) return;
// The snapshot's seed wins over the task-payload seed for any
// overlapping ids (the snapshot represents the agent's
// canonical accumulator, post-turn).
pendingRef.current.set(message.id, message);
if (!timestampsRef.current.has(message.id)) {
timestampsRef.current.set(message.id, snapshot.savedAt - count + i);
}
});
scheduleFlush.current();
return snapshot.lastOutEventId;
} catch {
// 404 / network / parse / abort — fall back to seq=0 SSE
return undefined;
}
};
const outputSubOptions = (lastEventId: string | undefined) =>
({
signal: abort.signal,
timeoutInSeconds: 120,
...(lastEventId !== undefined ? { lastEventId } : {}),
}) as const;
const commonSubOptions = {
signal: abort.signal,
timeoutInSeconds: 120,
} as const;
// ---- Output stream: assistant messages ---------------------------------
//
// The output stream delivers data records (UIMessageChunks) interleaved
// with Trigger control records (`turn-complete`, `upgrade-required`) and
// S2 command records (`trim`). Control + command records ride on
// `record.headers` with empty bodies; the SSE parser strips S2 command
// records entirely, and control records arrive with `value.chunk ===
// undefined`, which `parseChunkPayload` drops below.
//
// We fold everything else into an assistant `UIMessage` via our own
// `applyOutputChunk` accumulator — the AI SDK's `readUIMessageStream`
// helper is only available in `ai@6`, and the webapp is pinned to
// `ai@4`, so we re-implement just the chunk types that `renderPart`
// actually displays.
//
// We capture the **server timestamp of each assistant message's first
// `start` chunk** so later sort-by-timestamp merges with the input
// stream correctly.
const runOutput = async () => {
try {
// Seed messages from the snapshot first (if available), then
// resume the SSE from the snapshot's last event id so we don't
// re-stream chunks already represented in the snapshot. If no
// snapshot exists (no URL, 404, parse failure), the SSE opens
// at seq=0 — which, post-trim, contains roughly one turn of
// records (acceptable fallback for `hydrateMessages` sessions
// and fresh sessions).
const snapshotLastEventId = await loadSnapshot();
if (abort.signal.aborted) return;
const sub = new SSEStreamSubscription(outputUrl, outputSubOptions(snapshotLastEventId));
const raw = await sub.subscribe();
const reader = raw.getReader();
let currentMessageId: string | null = null;
try {
while (!abort.signal.aborted) {
const { done, value } = await reader.read();
if (done) return;
const chunk = parseChunkPayload(value.chunk) as OutputChunk | null;
if (!chunk || typeof chunk.type !== "string") continue;
// Legacy belt-and-suspenders: prior versions of the SDK
// emitted `trigger:turn-complete` / `trigger:upgrade-required`
// as data records (`type` field). Current versions use
// header-form control records, which `parseChunkPayload`
// drops above. Keep this filter to handle any in-flight
// sessions whose `.out` was populated by the older SDK.
if (chunk.type.startsWith("trigger:")) continue;
if (chunk.type === "start") {
const messageId =
typeof chunk.messageId === "string" && chunk.messageId.length > 0
? chunk.messageId
: `asst-${crypto.randomUUID()}`;
currentMessageId = messageId;
if (!timestampsRef.current.has(messageId)) {
timestampsRef.current.set(messageId, value.timestamp);
}
const existing = pendingRef.current.get(messageId);
if (existing) {
// Same message id seen again — merge metadata only, keep
// existing parts (canonical `processUIMessageStream` does
// the same on a repeated `start`).
if (chunk.messageMetadata != null) {
pendingRef.current.set(messageId, {
...existing,
metadata: {
...((existing as { metadata?: Record<string, unknown> }).metadata ?? {}),
...(chunk.messageMetadata as Record<string, unknown>),
},
} as UIMessage);
scheduleFlush.current();
}
} else {
const message: UIMessage = {
id: messageId,
role: "assistant",
parts: [],
...(chunk.messageMetadata != null
? { metadata: chunk.messageMetadata as UIMessage["metadata"] }
: {}),
} as UIMessage;
pendingRef.current.set(messageId, message);
orchestrationRef.current.set(messageId, createOrchestrationState());
scheduleFlush.current();
}
continue;
}
if (currentMessageId === null) continue;
const existing = pendingRef.current.get(currentMessageId);
if (!existing) continue;
let orchestration = orchestrationRef.current.get(currentMessageId);
if (!orchestration) {
// Defensive: a chunk arrived for a message we never saw a
// `start` for. Lazily create orchestration state so we can
// still display the parts.
orchestration = createOrchestrationState();
orchestrationRef.current.set(currentMessageId, orchestration);
}
const updated = applyOutputChunk(existing, chunk, orchestration);
if (updated !== existing) {
pendingRef.current.set(currentMessageId, updated);
scheduleFlush.current();
}
// A `.out` chunk just established/updated a tool part — (re)apply any
// buffered `.in` resolution for it. Covers the `.in`-before-`.out`
// order and corrects a `.out` chunk that downgraded the state (e.g.
// a replayed `tool-approval-request` arriving after the denial).
const outToolCallId = (chunk as { toolCallId?: string }).toolCallId;
if (typeof outToolCallId === "string" && applyToolResolution(outToolCallId)) {
scheduleFlush.current();
}
}
} finally {
try {
reader.releaseLock();
} catch {
// Lock may already be released.
}
}
} catch (err) {
if (abort.signal.aborted) return;
// eslint-disable-next-line no-console
console.debug("[AgentView] output stream subscription failed", err);
}
};
// ---- Input channel: user messages (`ChatInputChunk`) -------------------
//
// The transport appends a `{kind: "message", payload}` ChatInputChunk
// for every user turn (and `{kind: "stop"}` for stop signals). We pull
// user messages out of `payload.messages` for `kind: "message"` chunks
// and ignore the rest.
const runInput = async () => {
try {
const sub = new SSEStreamSubscription(inputUrl, commonSubOptions);
const raw = await sub.subscribe();
const reader = raw.getReader();
try {
while (!abort.signal.aborted) {
const { done, value } = await reader.read();
if (done) return;
const chunk = parseChunkPayload(value.chunk) as InputStreamChunk | null;
if (!chunk || chunk.kind !== "message") continue;
const payload = chunk.payload;
if (!payload) continue;
// Slim-wire is one UIMessage on `payload.message`; legacy
// payloads carried an array on `payload.messages`. Accept
// either so historical records on a long-lived session still
// render.
const candidates = Array.isArray(payload.messages)
? payload.messages
: payload.message
? [payload.message]
: [];
let changed = false;
// New user turns — merge in (dedupe by id).
for (const m of candidates) {
if (
m == null ||
(m as { role?: string }).role !== "user" ||
typeof m.id !== "string"
) {
continue;
}
if (pendingRef.current.has(m.id)) continue;
pendingRef.current.set(m.id, m as UIMessage);
timestampsRef.current.set(m.id, value.timestamp);
changed = true;
}
// HITL resolutions ride on `.in` as a slim *assistant* message
// carrying just the resolved tool part (state + approval/output).
// Buffer each by toolCallId and overlay onto the matching tool part
// (which usually arrived on `.out` as `tool-approval-request`).
for (const m of candidates) {
if (m == null || (m as { role?: string }).role !== "assistant") continue;
const parts = (m as { parts?: unknown[] }).parts;
if (!Array.isArray(parts)) continue;
for (const sp of parts) {
const part = sp as Record<string, unknown>;
if (typeof part.type !== "string" || !part.type.startsWith("tool-")) continue;
const tcId = (part as { toolCallId?: string }).toolCallId;
if (typeof tcId !== "string") continue;
pendingResolutionsRef.current.set(tcId, part);
if (applyToolResolution(tcId)) changed = true;
}
}
if (changed) scheduleFlush.current();
}
} finally {
try {
reader.releaseLock();
} catch {
// Lock may already be released.
}
}
} catch (err) {
if (abort.signal.aborted) return;
// eslint-disable-next-line no-console
console.debug("[AgentView] input stream subscription failed", err);
}
};
void runOutput();
void runInput();
return () => {
abort.abort();
if (pendingTimerRef.current !== null) {
clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = null;
}
};
// `snapshotPresignedUrl` is intentionally NOT in this dep list — see
// `snapshotUrlRef` above for the reasoning. Including it caused the
// subscription to tear down + replay on every inspector tab click,
// which appended duplicate parts to any assistant message whose
// chunks lived past `snapshot.lastOutEventId`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, apiOrigin, orgSlug, projectSlug, envSlug]);
return useMemo(() => {
const timestamps = timestampsRef.current;
const arr = Array.from(messagesById.values());
arr.sort((a, b) => {
const ta = timestamps.get(a.id) ?? 0;
const tb = timestamps.get(b.id) ?? 0;
if (ta !== tb) return ta - tb;
// Tie-breaker for messages sharing a stream ID bucket (rare): fall
// back to message id string order so the output is deterministic.
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
return arr;
}, [messagesById]);
}
// ---------------------------------------------------------------------------
// applyOutputChunk — minimal UIMessageChunk → UIMessage accumulator.
// ---------------------------------------------------------------------------
//
// A pared-down re-implementation of AI SDK's `processUIMessageStream` (in
// `ai@6`'s `index.mjs`). The webapp is pinned to `ai@4`, which doesn't ship
// the v5+ chunk-stream helpers, so we vendor the bits we actually use.
//
// Scope vs. canonical:
// - We render only the chunk shapes that `AgentMessageView`/`renderPart`
// actually display: text, reasoning, tool-* (input-{start,delta,available}
// + output-{available,error}), source-url, source-document, file,
// step-start/finish-step, data-*, plus metadata/finish lifecycle.
// - Unknown chunk types fall through as no-ops — defensive on purpose for a
// read-only viewer.
// - We **do not parse partial JSON for streaming tool inputs.** Canonical
// uses `parsePartialJson` (which depends on a 300-line `fixJson` state
// machine to repair incomplete JSON) so users see the input growing
// character-by-character. We skip it: tool inputs stay `undefined`
// throughout streaming and snap to the final value when
// `tool-input-available` lands. Acceptable for a viewer; can be added
// later by vendoring `fixJson` if the UX warrants it.
//
// `orchestration` carries per-message active-part trackers that mirror
// canonical's `state.activeTextParts` / `state.activeReasoningParts`. They
// let `text-delta` find the right text part by id and let `finish-step`
// clear them so a new step can re-use the same id without colliding.
//
// Returns the same object reference when nothing changes so the caller can
// skip unnecessary state flushes + React re-renders.
type AnyPart = { [key: string]: unknown; type: string };
function applyOutputChunk(
msg: UIMessage,
chunk: OutputChunk,
orchestration: MessageOrchestrationState
): UIMessage {
const type = chunk.type;
// Text parts ---------------------------------------------------------------
//
// Track each streaming text part by its index in `msg.parts`. Part ids
// are only unique *within a step* — the SDK happily reuses `text-start
// id="0"` after a `finish-step` boundary — so a delta arriving for a
// reused id needs to land on the *current* part, not every prior part
// that ever shared that id. The index map gives us O(1) "which slot is
// currently streaming this id" without any id-based search.
if (type === "text-start") {
const id = chunk.id as string;
const newIndex = (msg.parts ?? []).length; // index AFTER push
orchestration.activeTextPartIndexes.set(id, newIndex);
return withNewPart(msg, {
type: "text",
id,
text: "",
state: "streaming",
});
}
if (type === "text-delta") {
const id = chunk.id as string;
const index = orchestration.activeTextPartIndexes.get(id);
if (index === undefined) return msg; // delta with no start — drop.
return updatePartAt(msg, index, (p) => ({
...p,
text: ((p as { text?: string }).text ?? "") + String(chunk.delta ?? ""),
}));
}
if (type === "text-end") {
const id = chunk.id as string;
const index = orchestration.activeTextPartIndexes.get(id);
if (index === undefined) return msg;
orchestration.activeTextPartIndexes.delete(id);
return updatePartAt(msg, index, (p) => ({ ...p, state: "done" }));
}
// Reasoning parts ----------------------------------------------------------
if (type === "reasoning-start") {
const id = chunk.id as string;
const newIndex = (msg.parts ?? []).length;
orchestration.activeReasoningPartIndexes.set(id, newIndex);
return withNewPart(msg, {
type: "reasoning",
id,
text: "",
state: "streaming",
});
}
if (type === "reasoning-delta") {
const id = chunk.id as string;
const index = orchestration.activeReasoningPartIndexes.get(id);
if (index === undefined) return msg;
return updatePartAt(msg, index, (p) => ({
...p,
text: ((p as { text?: string }).text ?? "") + String(chunk.delta ?? ""),
}));
}
if (type === "reasoning-end") {
const id = chunk.id as string;
const index = orchestration.activeReasoningPartIndexes.get(id);
if (index === undefined) return msg;
orchestration.activeReasoningPartIndexes.delete(id);
return updatePartAt(msg, index, (p) => ({ ...p, state: "done" }));
}
// Tool call parts ----------------------------------------------------------
if (type === "tool-input-start") {
const toolName = String(chunk.toolName ?? "");
return withNewPart(msg, {
type: `tool-${toolName}`,
toolCallId: chunk.toolCallId,
toolName,
state: "input-streaming",
input: undefined,
});
}
if (type === "tool-input-delta") {
// We don't parse partial JSON, so streaming tool input deltas are a
// no-op. The full input snaps in when `tool-input-available` arrives.
return msg;
}
if (type === "tool-input-available") {
const toolName = String(chunk.toolName ?? "");
const existingIdx = indexOfPart(
msg,
(p) => (p as { toolCallId?: string }).toolCallId === chunk.toolCallId
);
if (existingIdx >= 0) {
return updatePartAt(msg, existingIdx, (p) => ({
...p,
state: "input-available",
input: chunk.input,
}));
}
// Tool input arrived without a preceding tool-input-start (some
// providers do this for fast tools) — synthesize a new part.
return withNewPart(msg, {
type: `tool-${toolName}`,
toolCallId: chunk.toolCallId,
toolName,
state: "input-available",
input: chunk.input,
});
}
if (type === "tool-output-available") {
return updatePart(msg, (p) =>
(p as { toolCallId?: string }).toolCallId === chunk.toolCallId
? {
...p,
state: "output-available",
output: chunk.output,
...(chunk.preliminary === true ? { preliminary: true } : {}),
}
: null
);
}
if (type === "tool-output-error") {
return updatePart(msg, (p) =>
(p as { toolCallId?: string }).toolCallId === chunk.toolCallId
? { ...p, state: "output-error", errorText: chunk.errorText }
: null
);
}
// HITL approval (AI SDK 7) -------------------------------------------------
//
// v7 added human-in-the-loop tool approval. A `needsApproval` tool emits a
// `tool-approval-request` after its input is available; the tool then waits
// for a `tool-approval-response` (approve/deny) before executing. Mirror AI
// SDK 7's `processUIMessageStream`: the request marks the matching part
// `approval-requested` and records `approval.id`; the response (matched by
// that id) marks it `approval-responded` with the verdict. An approved tool
// then proceeds to `tool-output-available` as usual.
if (type === "tool-approval-request") {
return updatePart(msg, (p) =>
(p as { toolCallId?: string }).toolCallId === chunk.toolCallId
? {
...p,
state: "approval-requested",
approval: {
id: chunk.approvalId,
...(chunk.isAutomatic === true ? { isAutomatic: true } : {}),
},
}
: null
);
}
if (type === "tool-approval-response") {
return updatePart(msg, (p) => {
const approval = (p as { approval?: { id?: string; isAutomatic?: boolean } }).approval;
if (!approval || approval.id !== chunk.approvalId) return null;
return {
...p,
state: "approval-responded",
approval: {
...approval,
id: chunk.approvalId,
approved: chunk.approved,
...(chunk.reason != null ? { reason: chunk.reason } : {}),
},
};
});
}
// Source / file / step / data parts — pass through as a whole -------------
if (type === "source-url" || type === "source-document" || type === "file") {
return withNewPart(msg, chunk as unknown as AnyPart);
}
if (type === "start-step") {
return withNewPart(msg, { type: "step-start" });
}
if (type === "finish-step") {
// Step boundary — canonical clears the active part trackers so a new
// step can re-use the same text/reasoning part IDs cleanly. The
// message itself doesn't structurally change; the previous step's
// parts stay frozen at their indexes in `msg.parts`.
orchestration.activeTextPartIndexes.clear();
orchestration.activeReasoningPartIndexes.clear();
return msg;
}
if (type.startsWith("data-")) {
return withNewPart(msg, chunk as unknown as AnyPart);
}
// Metadata / lifecycle -----------------------------------------------------
if (type === "finish" || type === "message-metadata") {
if (chunk.messageMetadata == null) return msg;
return {
...msg,
metadata: {
...((msg as { metadata?: Record<string, unknown> }).metadata ?? {}),
...(chunk.messageMetadata as Record<string, unknown>),
},
} as UIMessage;
}
// Abort / error / unknown — no structural change. (`start` is handled at
// the orchestration level in the output reader, not here.)
return msg;
}
// --- Small immutable helpers for UIMessage.parts mutation -------------------
function withNewPart(msg: UIMessage, part: AnyPart): UIMessage {
return {
...msg,
parts: [...((msg.parts ?? []) as AnyPart[]), part],
} as UIMessage;
}
function updatePart(msg: UIMessage, updater: (part: AnyPart) => AnyPart | null): UIMessage {
const parts = (msg.parts ?? []) as AnyPart[];
let changed = false;
const next = parts.map((p) => {
const updated = updater(p);
if (updated === null) return p;
changed = true;
return updated;
});
return changed ? ({ ...msg, parts: next } as UIMessage) : msg;
}
function indexOfPart(msg: UIMessage, predicate: (part: AnyPart) => boolean): number {
const parts = (msg.parts ?? []) as AnyPart[];
for (let i = 0; i < parts.length; i++) {
if (predicate(parts[i]!)) return i;
}
return -1;
}
function updatePartAt(
msg: UIMessage,
index: number,
updater: (part: AnyPart) => AnyPart
): UIMessage {
const parts = (msg.parts ?? []) as AnyPart[];
if (index < 0 || index >= parts.length) return msg;
const next = parts.slice();
next[index] = updater(parts[index]!);
return { ...msg, parts: next } as UIMessage;
}
@@ -0,0 +1,497 @@
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
import { Clipboard, ClipboardCheck } from "lucide-react";
import { Suspense, useEffect, useState } from "react";
import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon";
import { TextSquareIcon } from "~/assets/icons/TextSquareIcon";
import { CodeBlock } from "~/components/code/CodeBlock";
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Header3 } from "~/components/primitives/Headers";
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { cn } from "~/utils/cn";
import type { DisplayItem, ToolUse } from "./types";
export type PromptLink = {
slug: string;
version?: string;
path: string;
};
export function AIChatMessages({
items,
promptLink,
}: {
items: DisplayItem[];
promptLink?: PromptLink;
}) {
return (
<div className="flex flex-col gap-1">
{items.map((item, i) => {
switch (item.type) {
case "system":
return <SystemSection key={i} text={item.text} promptLink={promptLink} />;
case "user":
return <UserSection key={i} text={item.text} />;
case "tool-use":
return <ToolUseSection key={i} tools={item.tools} />;
case "assistant":
return <AssistantResponse key={i} text={item.text} />;
}
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Section header (shared across all sections)
// ---------------------------------------------------------------------------
function SectionHeader({ label, right }: { label: string; right?: React.ReactNode }) {
return (
<div className="flex items-center justify-between">
<Header3>{label}</Header3>
{right && <div className="flex items-center gap-2">{right}</div>}
</div>
);
}
export function ChatBubble({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-md border border-grid-bright bg-background-hover/50 px-3.5 py-2">
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
function SystemSection({ text, promptLink }: { text: string; promptLink?: PromptLink }) {
const [expanded, setExpanded] = useState(false);
const isLong = text.length > 150;
const preview = isLong ? text.slice(0, 150) + "..." : text;
const displayText = expanded || !isLong ? text : preview;
return (
<div className="flex flex-col gap-1.5 py-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Header3>System</Header3>
{promptLink && (
<LinkButton to={promptLink.path} variant="minimal/small">
<span className="flex items-center gap-1">
<svg className="size-3.5 shrink-0 text-text-dimmed">
<use xlinkHref={`${tablerSpritePath}#tabler-file-text-ai`} />
</svg>
{promptLink.slug}
{promptLink.version ? ` v${promptLink.version}` : ""}
</span>
</LinkButton>
)}
</div>
{isLong && (
<Button
variant="minimal/small"
onClick={() => setExpanded(!expanded)}
LeadingIcon={expanded ? ChevronUpIcon : ChevronDownIcon}
aria-label={expanded ? "Collapse" : "Expand"}
aria-expanded={expanded}
/>
)}
</div>
<ChatBubble>
<div className="streamdown-container font-sans text-sm font-normal text-text-dimmed">
<Suspense fallback={<span className="whitespace-pre-wrap">{displayText}</span>}>
<StreamdownRenderer>{displayText}</StreamdownRenderer>
</Suspense>
</div>
</ChatBubble>
</div>
);
}
// ---------------------------------------------------------------------------
// User
// ---------------------------------------------------------------------------
function UserSection({ text }: { text: string }) {
return (
<div className="flex flex-col gap-1.5 py-2.5">
<SectionHeader label="User" />
<ChatBubble>
<div className="streamdown-container font-sans text-sm font-normal text-text-dimmed">
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
<StreamdownRenderer>{text}</StreamdownRenderer>
</Suspense>
</div>
</ChatBubble>
</div>
);
}
// ---------------------------------------------------------------------------
// Assistant response (with markdown/raw toggle)
// ---------------------------------------------------------------------------
function isJsonString(value: string): boolean {
const trimmed = value.trimStart();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}
export function AssistantResponse({
text,
headerLabel = "Assistant",
}: {
text: string;
headerLabel?: string;
}) {
const isJson = isJsonString(text);
const [mode, setMode] = useState<"rendered" | "raw">("rendered");
const [copied, setCopied] = useState(false);
function handleCopy() {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (isJson) {
return (
<div className="flex flex-col gap-1.5 py-2.5">
<SectionHeader label={headerLabel} />
<CodeBlock
code={text}
maxLines={20}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
);
}
return (
<div className="flex flex-col gap-1.5 py-2.5">
<SectionHeader
label={headerLabel}
right={
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger
onClick={() => setMode(mode === "rendered" ? "raw" : "rendered")}
className="text-text-dimmed transition-colors duration-100 focus-custom hover:cursor-pointer hover:text-text-bright"
>
{mode === "rendered" ? (
<CodeSquareIcon className="size-4.5" />
) : (
<TextSquareIcon className="size-4.5" />
)}
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{mode === "rendered" ? "Show raw" : "Show rendered"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip open={copied || undefined} disableHoverableContent>
<TooltipTrigger
onClick={handleCopy}
className={cn(
"transition-colors duration-100 focus-custom hover:cursor-pointer",
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheck className="size-4" />
) : (
<Clipboard className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{copied ? "Copied" : "Copy"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
/>
{mode === "rendered" ? (
<ChatBubble>
<div className="streamdown-container min-w-0 font-sans text-sm font-normal text-text-dimmed wrap-anywhere">
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
<StreamdownRenderer>{text}</StreamdownRenderer>
</Suspense>
</div>
</ChatBubble>
) : (
<CodeBlock
code={text}
maxLines={20}
showLineNumbers={false}
showCopyButton={false}
className="pl-2"
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Tool use (merged calls + results)
// ---------------------------------------------------------------------------
function ToolUseSection({ tools }: { tools: ToolUse[] }) {
return (
<div className="flex flex-col gap-1.5 py-2.5">
<SectionHeader label={tools.length === 1 ? "Tool call" : `Tool calls (${tools.length})`} />
<div className="flex flex-col gap-2">
{tools.map((tool) => (
<ToolUseRow key={tool.toolCallId} tool={tool} />
))}
</div>
</div>
);
}
type ToolTab = "input" | "output" | "details" | "agent";
export function ToolUseRow({ tool }: { tool: ToolUse }) {
const hasInput = tool.inputJson !== "{}";
const hasResult = !!tool.resultOutput;
const hasDetails = !!tool.description || !!tool.parametersJson;
const hasSubAgent = !!tool.subAgent;
const availableTabs: ToolTab[] = [
...(hasSubAgent ? (["agent"] as const) : []),
...(hasInput ? (["input"] as const) : []),
...(hasResult ? (["output"] as const) : []),
...(hasDetails ? (["details"] as const) : []),
];
const [activeTab, setActiveTab] = useState<ToolTab | null>(
hasSubAgent ? "agent" : hasInput ? "input" : null
);
// Auto-select input tab when input arrives after initial render (e.g. streaming tool calls)
useEffect(() => {
if (!hasSubAgent && hasInput && activeTab === null) {
setActiveTab("input");
}
}, [hasInput, hasSubAgent]);
function handleTabClick(tab: ToolTab) {
setActiveTab(activeTab === tab ? null : tab);
}
return (
<div
className={`rounded-sm border bg-background-bright/40 ${
hasSubAgent ? "border-indigo-500/30" : "border-grid-bright"
}`}
>
<div className="flex items-center gap-2 px-2.5 py-1.5">
{hasSubAgent && (
<svg
className="size-3.5 text-indigo-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z"
/>
</svg>
)}
<code
className={`font-mono text-xs ${hasSubAgent ? "text-indigo-300" : "text-text-bright"}`}
>
{tool.toolName}
</code>
{hasSubAgent && tool.subAgent?.isStreaming && (
<span className="flex items-center gap-1 text-[10px] text-indigo-400">
<span className="inline-block size-1.5 animate-pulse rounded-full bg-indigo-400" />
streaming
</span>
)}
{tool.resultSummary && (
<span className="ml-auto text-[10px] text-text-dimmed">{tool.resultSummary}</span>
)}
</div>
{availableTabs.length > 0 && (
<>
<div
className={`flex gap-0 border-t ${
hasSubAgent ? "border-indigo-500/20" : "border-grid-bright"
}`}
>
{availableTabs.map((tab) => (
<button
key={tab}
onClick={() => handleTabClick(tab)}
className={`px-2.5 py-1 text-[11px] capitalize transition-colors ${
activeTab === tab
? "bg-background-hover text-text-bright"
: "text-text-dimmed hover:text-text-bright"
}`}
>
{tab}
</button>
))}
</div>
{activeTab === "agent" && hasSubAgent && <SubAgentContent parts={tool.subAgent!.parts} />}
{activeTab === "input" && hasInput && (
<div className="border-t border-grid-dimmed">
<CodeBlock
code={tool.inputJson}
maxLines={12}
showLineNumbers={false}
showCopyButton
className="rounded-none border-0"
/>
</div>
)}
{activeTab === "output" && hasResult && (
<div className="border-t border-grid-dimmed">
{isJsonString(tool.resultOutput!) ? (
<CodeBlock
code={tool.resultOutput!}
maxLines={16}
showLineNumbers={false}
showCopyButton
className="rounded-none border-0"
/>
) : (
<div className="streamdown-container p-2.5 font-sans text-sm font-normal text-text-dimmed">
<Suspense
fallback={<span className="whitespace-pre-wrap">{tool.resultOutput}</span>}
>
<StreamdownRenderer>{tool.resultOutput!}</StreamdownRenderer>
</Suspense>
</div>
)}
</div>
)}
{activeTab === "details" && hasDetails && (
<div className="flex flex-col gap-2 border-t border-grid-dimmed px-2.5 py-2">
{tool.description && (
<p className="text-xs leading-relaxed text-text-dimmed">{tool.description}</p>
)}
{tool.parametersJson && (
<div>
<span className="text-[10px] font-medium uppercase tracking-wide text-text-dimmed">
Parameters schema
</span>
<CodeBlock
code={tool.parametersJson}
maxLines={16}
showLineNumbers={false}
showCopyButton
className="rounded-none border-0"
/>
</div>
)}
</div>
)}
</>
)}
</div>
);
}
function SubAgentContent({ parts }: { parts: any[] }) {
// Extract sub-agent run ID from injected metadata part
const runPart = parts.find((p: any) => p.type === "data-subagent-run" && p.data?.runId);
const subAgentRunId = runPart?.data?.runId as string | undefined;
return (
<div className="space-y-2 border-t border-indigo-500/20 p-2.5">
{subAgentRunId && (
<div className="flex justify-end">
<LinkButton to={`/runs/${subAgentRunId}`} variant="tertiary/small" target="_blank">
View sub-agent run
</LinkButton>
</div>
)}
{parts.map((part: any, j: number) => {
const partType = part.type as string;
// Skip the injected metadata part — already rendered above
if (partType === "data-subagent-run") return null;
if (partType === "text" && part.text) {
return <AssistantResponse key={j} text={part.text} headerLabel="" />;
}
if (partType === "step-start") {
return (
<div key={j} className="flex items-center gap-2 py-0.5">
<div className="flex-1 border-t border-dashed border-border-bright" />
<span className="text-[10px] text-text-faint">step</span>
<div className="flex-1 border-t border-dashed border-border-bright" />
</div>
);
}
if (partType.startsWith("tool-")) {
const subToolName = partType.slice(5);
return (
<ToolUseRow
key={j}
tool={{
toolCallId: part.toolCallId ?? `sub-tool-${j}`,
toolName: subToolName,
inputJson: JSON.stringify(part.input ?? {}, null, 2),
resultOutput:
part.output != null
? typeof part.output === "string"
? part.output
: JSON.stringify(part.output, null, 2)
: undefined,
resultSummary:
part.state === "input-streaming" || part.state === "input-available"
? "calling..."
: part.state === "output-error"
? `error: ${part.errorText ?? "unknown"}`
: undefined,
}}
/>
);
}
if (partType === "reasoning" && part.text) {
return (
<div key={j} className="border-l-2 border-amber-500/40 pl-2">
<div className="whitespace-pre-wrap text-xs italic text-amber-200/70">
{part.text}
</div>
</div>
);
}
return null;
})}
</div>
);
}
@@ -0,0 +1,65 @@
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { formatDuration } from "./aiHelpers";
import { SpanMetricRow as MetricRow } from "./SpanMetricRow";
export type AIEmbedData = {
model: string;
provider: string;
value?: string;
durationMs: number;
};
export function extractAIEmbedData(
properties: Record<string, unknown>,
durationMs: number
): AIEmbedData | undefined {
const ai = properties.ai;
if (!ai || typeof ai !== "object") return undefined;
const a = ai as Record<string, unknown>;
if (a.operationId !== "ai.embed") return undefined;
const aiModel = a.model;
if (!aiModel || typeof aiModel !== "object") return undefined;
const m = aiModel as Record<string, unknown>;
const model = typeof m.id === "string" ? m.id : undefined;
if (!model) return undefined;
return {
model,
provider: typeof m.provider === "string" ? m.provider : "unknown",
value: typeof a.value === "string" ? a.value : undefined,
durationMs,
};
}
export function AIEmbedSpanDetails({ data }: { data: AIEmbedData }) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="flex flex-col px-3">
{/* Model info */}
<div className="flex flex-col gap-1 py-2.5">
<div className="flex flex-col text-xs @container">
<MetricRow label="Model" value={data.model} />
<MetricRow label="Provider" value={data.provider} />
<MetricRow label="Duration" value={formatDuration(data.durationMs)} />
</div>
</div>
{/* Input value */}
{data.value && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Input</Header3>
<div className="rounded-md border border-grid-bright bg-background-hover/50 px-3.5 py-2">
<Paragraph variant="small/dimmed">{data.value}</Paragraph>
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,150 @@
import type { ReactNode } from "react";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import { Header3 } from "~/components/primitives/Headers";
import { TextLink } from "~/components/primitives/TextLink";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { v3PromptPath } from "~/utils/pathBuilder";
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
import type { AISpanData } from "./types";
export function AITagsRow({ aiData }: { aiData: AISpanData }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const promptLink =
aiData.promptSlug && organization && project && environment
? v3PromptPath(organization, project, environment, aiData.promptSlug, aiData.promptVersion)
: undefined;
return (
<div className="flex flex-col gap-1 py-2.5">
<div className="flex flex-col text-xs @container">
{aiData.responseId && (
<MetricRow
label="Response ID"
value={<TruncatedCopyableValue value={aiData.responseId} />}
/>
)}
<MetricRow label="Model" value={aiData.model} />
{aiData.provider !== "unknown" && <MetricRow label="Provider" value={aiData.provider} />}
{aiData.resolvedProvider && (
<MetricRow label="Resolved provider" value={aiData.resolvedProvider} />
)}
{aiData.promptSlug && (
<MetricRow
label="Prompt"
value={
promptLink ? (
<TextLink to={promptLink}>
{aiData.promptSlug}
{aiData.promptVersion ? ` v${aiData.promptVersion}` : ""}
</TextLink>
) : (
`${aiData.promptSlug}${aiData.promptVersion ? ` v${aiData.promptVersion}` : ""}`
)
}
/>
)}
{aiData.finishReason && <MetricRow label="Finish reason" value={aiData.finishReason} />}
{aiData.serviceTier && <MetricRow label="Service tier" value={aiData.serviceTier} />}
{aiData.toolChoice && <MetricRow label="Tool choice" value={aiData.toolChoice} />}
{aiData.toolCount != null && aiData.toolCount > 0 && (
<MetricRow
label="Tools provided"
value={`${aiData.toolCount} ${aiData.toolCount === 1 ? "tool" : "tools"}`}
/>
)}
{aiData.messageCount != null && (
<MetricRow
label="Messages"
value={`${aiData.messageCount} ${aiData.messageCount === 1 ? "message" : "messages"}`}
/>
)}
{aiData.telemetryMetadata &&
Object.entries(aiData.telemetryMetadata)
.filter(([key]) => key !== "prompt")
.map(([key, value]) => <MetricRow key={key} label={key} value={value} />)}
</div>
</div>
);
}
export function AIStatsSummary({ aiData }: { aiData: AISpanData }) {
return (
<div className="flex flex-col gap-1 py-2.5">
<Header3>Stats</Header3>
<div className="flex flex-col text-xs @container">
<MetricRow label="Input" value={aiData.inputTokens.toLocaleString()} unit="tokens" />
<MetricRow label="Output" value={aiData.outputTokens.toLocaleString()} unit="tokens" />
{aiData.cachedTokens != null && aiData.cachedTokens > 0 && (
<MetricRow
label="Cache read"
value={aiData.cachedTokens.toLocaleString()}
unit="tokens"
/>
)}
{aiData.cacheCreationTokens != null && aiData.cacheCreationTokens > 0 && (
<MetricRow
label="Cache write"
value={aiData.cacheCreationTokens.toLocaleString()}
unit="tokens"
/>
)}
{aiData.reasoningTokens != null && aiData.reasoningTokens > 0 && (
<MetricRow
label="Reasoning"
value={aiData.reasoningTokens.toLocaleString()}
unit="tokens"
/>
)}
<MetricRow label="Total" value={aiData.totalTokens.toLocaleString()} unit="tokens" bold />
{aiData.totalCost != null && (
<MetricRow label="Cost" value={formatCurrencyAccurate(aiData.totalCost)} />
)}
{aiData.msToFirstChunk != null && (
<MetricRow label="TTFC" value={formatTtfc(aiData.msToFirstChunk)} />
)}
{aiData.tokensPerSecond != null && (
<MetricRow label="Speed" value={`${Math.round(aiData.tokensPerSecond)} tok/s`} />
)}
</div>
</div>
);
}
function MetricRow({
label,
value,
unit,
bold,
}: {
label: string;
value: ReactNode;
unit?: string;
bold?: boolean;
}) {
return (
<div className="grid h-7 grid-cols-[1fr_auto] items-center gap-4 rounded-sm px-1.5 transition odd:bg-background-hover/40 @[28rem]:grid-cols-[8rem_1fr] hover:bg-white/4">
<span className="text-text-dimmed">{label}</span>
<span
className={`text-right @[28rem]:text-left ${
bold ? "font-medium text-text-bright" : "text-text-bright"
}`}
>
{value}
{unit && <span className="ml-1 text-text-dimmed">{unit}</span>}
</span>
</div>
);
}
function formatTtfc(ms: number): string {
if (ms >= 10_000) {
return `${(ms / 1000).toFixed(1)}s`;
}
return `${Math.round(ms)}ms`;
}
@@ -0,0 +1,357 @@
import { CheckIcon, ClipboardDocumentIcon } from "@heroicons/react/20/solid";
import { Suspense, useState } from "react";
import { Button } from "~/components/primitives/Buttons";
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import { tryPrettyJson } from "./aiHelpers";
import { SpanMetricRow as PromptMetricRow } from "./SpanMetricRow";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useHasAdminAccess } from "~/hooks/useUser";
import { v3PromptPath } from "~/utils/pathBuilder";
import { CodeBlock } from "~/components/code/CodeBlock";
import { AIChatMessages, AssistantResponse, ChatBubble, type PromptLink } from "./AIChatMessages";
import { AIStatsSummary, AITagsRow } from "./AIModelSummary";
import { AIToolsInventory } from "./AIToolsInventory";
import type { AISpanData, DisplayItem } from "./types";
import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server";
import { SpanHorizontalTimeline } from "~/components/runs/v3/SpanHorizontalTimeline";
type AITab = "overview" | "messages" | "tools" | "prompt";
export function AISpanDetails({
aiData,
promptVersionData,
rawProperties,
startTime,
duration,
}: {
aiData: AISpanData;
promptVersionData?: PromptSpanData;
rawProperties?: string;
startTime?: string | Date;
duration?: number | null;
}) {
const [tab, setTab] = useState<AITab>("overview");
const isAdmin = useHasAdminAccess();
const toolCount = aiData.toolCount ?? aiData.toolDefinitions?.length ?? 0;
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const promptLink: PromptLink | undefined =
aiData.promptSlug && organization && project && environment
? {
slug: aiData.promptSlug,
version: aiData.promptVersion,
path: v3PromptPath(
organization,
project,
environment,
aiData.promptSlug,
aiData.promptVersion
),
}
: undefined;
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Tab bar */}
<div className="shrink-0 overflow-x-auto px-3 py-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<TabContainer>
<TabButton
isActive={tab === "overview"}
layoutId="ai-span"
onClick={() => setTab("overview")}
shortcut={{ key: "o" }}
>
Overview
</TabButton>
<TabButton
isActive={tab === "messages"}
layoutId="ai-span"
onClick={() => setTab("messages")}
shortcut={{ key: "m" }}
>
Messages
</TabButton>
<TabButton
isActive={tab === "tools"}
layoutId="ai-span"
onClick={() => setTab("tools")}
shortcut={{ key: "t" }}
>
<span className="inline-flex items-center whitespace-nowrap">
Tools
{toolCount > 0 && (
<span className="ml-1 inline-flex min-w-4 -translate-y-px items-center justify-center rounded-full border border-border-bright bg-secondary px-1 py-0.5 text-[0.625rem] font-medium leading-none text-text-bright">
{toolCount}
</span>
)}
</span>
</TabButton>
{promptVersionData && (
<TabButton
isActive={tab === "prompt"}
layoutId="ai-span"
onClick={() => setTab("prompt")}
shortcut={{ key: "p" }}
>
Prompt
</TabButton>
)}
</TabContainer>
</div>
{/* Tab content */}
<div className="scrollbar-gutter-stable min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
{tab === "overview" && (
<>
{startTime && (
<div className="px-3">
<SpanHorizontalTimeline startTime={startTime} duration={duration ?? null} />
</div>
)}
<OverviewTab aiData={aiData} />
</>
)}
{tab === "messages" && <MessagesTab aiData={aiData} promptLink={promptLink} />}
{tab === "tools" && <ToolsTab aiData={aiData} />}
{tab === "prompt" && promptVersionData && (
<PromptTab promptData={promptVersionData} promptLink={promptLink} />
)}
</div>
{/* Footer: Copy raw (admin only) */}
{isAdmin && rawProperties && <CopyRawFooter rawProperties={rawProperties} />}
</div>
);
}
function OverviewTab({ aiData }: { aiData: AISpanData }) {
const { userText, outputText, outputObject, outputToolNames } = extractInputOutput(aiData);
return (
<div className="flex flex-col px-3">
<AITagsRow aiData={aiData} />
<AIStatsSummary aiData={aiData} />
{userText && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Input</Header3>
<ChatBubble>
<Paragraph variant="small/dimmed">{userText}</Paragraph>
</ChatBubble>
</div>
)}
{outputText && <AssistantResponse text={outputText} headerLabel="Output" />}
{!outputText && outputObject && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Output</Header3>
<CodeBlock
code={outputObject}
maxLines={20}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
)}
{outputToolNames.length > 0 && !outputText && !outputObject && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Output</Header3>
<ChatBubble>
<Paragraph variant="small/dimmed">
Called {outputToolNames.length === 1 ? "tool" : "tools"}:{" "}
<span className="font-mono text-text-bright">{outputToolNames.join(", ")}</span>
</Paragraph>
</ChatBubble>
</div>
)}
</div>
);
}
function MessagesTab({ aiData, promptLink }: { aiData: AISpanData; promptLink?: PromptLink }) {
const showFallbackText = aiData.responseText && !hasAssistantItem(aiData.items);
const showFallbackObject =
!showFallbackText && aiData.responseObject && !hasAssistantItem(aiData.items);
return (
<div className="px-3">
<div className="flex flex-col">
{aiData.items && aiData.items.length > 0 && (
<AIChatMessages items={aiData.items} promptLink={promptLink} />
)}
{showFallbackText && <AssistantResponse text={aiData.responseText!} />}
{showFallbackObject && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Assistant</Header3>
<CodeBlock
code={aiData.responseObject!}
maxLines={20}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
)}
</div>
</div>
);
}
function ToolsTab({ aiData }: { aiData: AISpanData }) {
return <AIToolsInventory aiData={aiData} />;
}
function CopyRawFooter({ rawProperties }: { rawProperties: string }) {
const [copied, setCopied] = useState(false);
function handleCopy() {
navigator.clipboard.writeText(rawProperties);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="flex h-13 shrink-0 items-center justify-end border-t border-grid-dimmed px-2">
<Button
variant="minimal/medium"
onClick={handleCopy}
LeadingIcon={copied ? CheckIcon : ClipboardDocumentIcon}
leadingIconClassName={copied ? "text-green-500" : undefined}
>
Copy raw properties
</Button>
</div>
);
}
function PromptTab({
promptData,
promptLink,
}: {
promptData: PromptSpanData;
promptLink?: PromptLink;
}) {
const labels = promptData.labels
? promptData.labels
.split(",")
.map((l) => l.trim())
.filter(Boolean)
: [];
return (
<div className="flex flex-col px-3">
{/* Prompt properties */}
<div className="flex flex-col gap-1 py-2.5">
<div className="flex flex-col text-xs @container">
<PromptMetricRow
label="Prompt"
value={
promptLink ? (
<TextLink to={promptLink.path}>{promptData.slug}</TextLink>
) : (
promptData.slug
)
}
/>
<PromptMetricRow label="Version" value={`v${promptData.version}`} />
{labels.length > 0 && <PromptMetricRow label="Labels" value={labels.join(", ")} />}
{promptData.model && <PromptMetricRow label="Model" value={promptData.model} />}
</div>
</div>
{/* Prompt input (variables passed to resolve()) */}
{promptData.input && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Input</Header3>
<CodeBlock
code={tryPrettyJson(promptData.input)}
maxLines={20}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
)}
{/* Template (from the prompt version) */}
{promptData.template && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Template</Header3>
<div className="rounded-md border border-grid-bright bg-background-hover/50 px-3.5 py-2">
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
<Suspense
fallback={<span className="whitespace-pre-wrap">{promptData.template}</span>}
>
<StreamdownRenderer>{promptData.template}</StreamdownRenderer>
</Suspense>
</div>
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function extractInputOutput(aiData: AISpanData): {
userText: string | undefined;
outputText: string | undefined;
outputObject: string | undefined;
outputToolNames: string[];
} {
let userText: string | undefined;
let outputText: string | undefined;
const outputToolNames: string[] = [];
if (aiData.items) {
for (let i = aiData.items.length - 1; i >= 0; i--) {
if (aiData.items[i].type === "user") {
userText = (aiData.items[i] as { type: "user"; text: string }).text;
break;
}
}
for (let i = aiData.items.length - 1; i >= 0; i--) {
const item = aiData.items[i];
if (item.type === "assistant") {
outputText = item.text;
break;
}
if (item.type === "tool-use") {
for (const tool of item.tools) {
outputToolNames.push(tool.toolName);
}
break;
}
}
}
if (!outputText && aiData.responseText) {
outputText = aiData.responseText;
}
return {
userText,
outputText,
outputObject: aiData.responseObject ? tryPrettyJson(aiData.responseObject) : undefined,
outputToolNames,
};
}
function hasAssistantItem(items: DisplayItem[] | undefined): boolean {
if (!items) return false;
return items.some((item) => item.type === "assistant");
}
@@ -0,0 +1,75 @@
import { Header3 } from "~/components/primitives/Headers";
import { CodeBlock } from "~/components/code/CodeBlock";
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
import { formatDuration, tryPrettyJson } from "./aiHelpers";
import { SpanMetricRow as MetricRow } from "./SpanMetricRow";
export type AIToolCallData = {
toolName: string;
toolCallId: string;
args?: string;
durationMs: number;
};
export function extractAIToolCallData(
properties: Record<string, unknown>,
durationMs: number
): AIToolCallData | undefined {
const ai = properties.ai;
if (!ai || typeof ai !== "object") return undefined;
const a = ai as Record<string, unknown>;
if (a.operationId !== "ai.toolCall") return undefined;
const toolCall = a.toolCall;
if (!toolCall || typeof toolCall !== "object") return undefined;
const tc = toolCall as Record<string, unknown>;
const toolName = typeof tc.name === "string" ? tc.name : undefined;
if (!toolName) return undefined;
return {
toolName,
toolCallId: typeof tc.id === "string" ? tc.id : "",
args: typeof tc.args === "string" ? tc.args : undefined,
durationMs,
};
}
export function AIToolCallSpanDetails({ data }: { data: AIToolCallData }) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="flex flex-col px-3">
{/* Tool info */}
<div className="flex flex-col gap-1 py-2.5">
<div className="flex flex-col text-xs @container">
<MetricRow label="Tool" value={data.toolName} />
{data.toolCallId && (
<MetricRow
label="Call ID"
value={<TruncatedCopyableValue value={data.toolCallId} />}
/>
)}
<MetricRow label="Duration" value={formatDuration(data.durationMs)} />
</div>
</div>
{/* Input args */}
{data.args && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Input</Header3>
<CodeBlock
code={tryPrettyJson(data.args)}
maxLines={20}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import { useState } from "react";
import { CodeBlock } from "~/components/code/CodeBlock";
import type { AISpanData, ToolDefinition } from "./types";
import { Paragraph } from "~/components/primitives/Paragraph";
export function AIToolsInventory({ aiData }: { aiData: AISpanData }) {
const defs = aiData.toolDefinitions ?? [];
const calledNames = getCalledToolNames(aiData);
if (defs.length === 0) {
return (
<div className="px-3 py-6 text-center">
<Paragraph variant="small/dimmed">No tool definitions available for this span.</Paragraph>
</div>
);
}
return (
<div className="flex flex-col divide-y divide-grid-bright px-3">
{defs.map((def) => {
const wasCalled = calledNames.has(def.name);
return <ToolDefRow key={def.name} def={def} wasCalled={wasCalled} />;
})}
</div>
);
}
function ToolDefRow({ def, wasCalled }: { def: ToolDefinition; wasCalled: boolean }) {
const [showSchema, setShowSchema] = useState(false);
return (
<div className="flex flex-col gap-1.5 py-2.5">
<div className="flex items-center gap-2">
<div
className={`size-1.5 shrink-0 rounded-full ${
wasCalled ? "bg-success" : "bg-surface-control"
}`}
/>
<code className="font-mono text-xs text-text-bright">{def.name}</code>
<span className="text-[10px] text-text-dimmed">{wasCalled ? "called" : "not called"}</span>
</div>
{def.description && (
<p className="pl-3.5 text-xs leading-relaxed text-text-dimmed">{def.description}</p>
)}
{def.parametersJson && (
<div className="pl-3.5">
<button
onClick={() => setShowSchema(!showSchema)}
className="text-[10px] text-text-link hover:underline"
>
{showSchema ? "Hide schema" : "Show schema"}
</button>
{showSchema && (
<div className="mt-1">
<CodeBlock
code={def.parametersJson}
maxLines={16}
showLineNumbers={false}
showCopyButton
/>
</div>
)}
</div>
)}
</div>
);
}
function getCalledToolNames(aiData: AISpanData): Set<string> {
const names = new Set<string>();
if (!aiData.items) return names;
for (const item of aiData.items) {
if (item.type === "tool-use") {
for (const tool of item.tools) {
names.add(tool.toolName);
}
}
}
return names;
}
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
export function SpanMetricRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="grid h-7 grid-cols-[1fr_auto] items-center gap-4 rounded-sm px-1.5 transition odd:bg-background-hover/40 @[28rem]:grid-cols-[8rem_1fr] hover:bg-white/4">
<span className="text-text-dimmed">{label}</span>
<span className="text-right text-text-bright @[28rem]:text-left">{value}</span>
</div>
);
}
@@ -0,0 +1,100 @@
// Shared primitive helpers for AI span data extraction
export function rec(v: unknown): Record<string, unknown> {
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
}
export function str(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
export function num(v: unknown): number | undefined {
return typeof v === "number" ? v : undefined;
}
export function tryPrettyJson(value: string): string {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
export function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const mins = Math.floor(ms / 60_000);
const secs = ((ms % 60_000) / 1000).toFixed(0);
return `${mins}m ${secs}s`;
}
/**
* Parse provider metadata from a JSON string.
* Handles Anthropic, Azure, OpenAI, Gateway, and OpenRouter formats.
*/
export function parseProviderMetadata(raw: unknown):
| {
serviceTier?: string;
resolvedProvider?: string;
gatewayCost?: string;
responseId?: string;
}
| undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object") return undefined;
let serviceTier: string | undefined;
let resolvedProvider: string | undefined;
let gatewayCost: string | undefined;
let responseId: string | undefined;
// Anthropic: { anthropic: { usage: { service_tier: "standard" } } }
const anthropic = rec(parsed.anthropic);
serviceTier = str(rec(anthropic.usage).service_tier);
// Azure/OpenAI: { azure: { serviceTier: "default" } } or { openai: { serviceTier: "..." } }
const openai = rec(parsed.openai);
if (!serviceTier) {
serviceTier = str(rec(parsed.azure).serviceTier) ?? str(openai.serviceTier);
}
// OpenAI response ID
responseId = str(openai.responseId);
// Gateway: { gateway: { routing: { finalProvider, resolvedProvider }, cost } }
const gateway = rec(parsed.gateway);
const routing = rec(gateway.routing);
resolvedProvider = str(routing.finalProvider) ?? str(routing.resolvedProvider);
gatewayCost = str(gateway.cost);
// OpenRouter: { openrouter: { provider: "xAI" } }
if (!resolvedProvider) {
resolvedProvider = str(rec(parsed.openrouter).provider);
}
if (!serviceTier && !resolvedProvider && !gatewayCost && !responseId) return undefined;
return { serviceTier, resolvedProvider, gatewayCost, responseId };
} catch {
return undefined;
}
}
/**
* Extract user-defined telemetry metadata, coercing non-string values.
* Skips the "prompt" key which is handled separately.
*/
export function extractTelemetryMetadata(raw: unknown): Record<string, string> | undefined {
if (!raw || typeof raw !== "object") return undefined;
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (key === "prompt") continue;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
result[key] = String(value);
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
@@ -0,0 +1,682 @@
import { rec, str, num, parseProviderMetadata, extractTelemetryMetadata } from "./aiHelpers";
import type { AISpanData, DisplayItem, ToolDefinition, ToolUse } from "./types";
/**
* Extracts structured AI span data from unflattened OTEL span properties.
*
* Works with the nested object produced by `unflattenAttributes()` — expects
* keys like `gen_ai.response.model`, `ai.prompt.messages`, `trigger.llm.total_cost`, etc.
*
* @param properties Unflattened span properties object
* @param durationMs Span duration in milliseconds
* @returns Structured AI data, or undefined if this isn't an AI generation span
*/
export function extractAISpanData(
properties: Record<string, unknown>,
durationMs: number
): AISpanData | undefined {
const genAi = properties.gen_ai;
if (!genAi || typeof genAi !== "object") return undefined;
const g = genAi as Record<string, unknown>;
const ai = rec(properties.ai);
const trigger = rec(properties.trigger);
const gResponse = rec(g.response);
const gRequest = rec(g.request);
const gUsage = rec(g.usage);
const gOperation = rec(g.operation);
const gProvider = rec(g.provider);
const gInput = rec(g.input);
const gOutput = rec(g.output);
const gTool = rec(g.tool);
const aiModel = rec(ai.model);
const aiResponse = rec(ai.response);
const aiPrompt = rec(ai.prompt);
const aiUsage = rec(ai.usage);
const triggerLlm = rec(trigger.llm);
const model = str(gResponse.model) ?? str(gRequest.model) ?? str(aiModel.id);
if (!model) return undefined;
// Prefer ai.usage (richer) over gen_ai.usage.
// Gateway/some providers emit promptTokens/completionTokens instead of inputTokens/outputTokens.
const inputTokens =
num(aiUsage.inputTokens) ?? num(aiUsage.promptTokens) ?? num(gUsage.input_tokens) ?? 0;
const outputTokens =
num(aiUsage.outputTokens) ?? num(aiUsage.completionTokens) ?? num(gUsage.output_tokens) ?? 0;
const totalTokens = num(aiUsage.totalTokens) ?? inputTokens + outputTokens;
const tokensPerSecond =
num(aiResponse.avgOutputTokensPerSecond) ??
(outputTokens > 0 && durationMs > 0
? Math.round((outputTokens / (durationMs / 1000)) * 10) / 10
: undefined);
// AI SDK 7 moves span emission into `@ai-sdk/otel`, which emits OTel GenAI
// semantic-convention attributes (gen_ai.input/output.messages, gen_ai.provider.name,
// gen_ai.tool.definitions, gen_ai.response.finish_reasons). AI SDK 6 emits the older
// `ai.*` keys (ai.prompt.messages, ai.response.text/toolCalls/finishReason). Customers
// run either major, so detect the shape and read both.
const isV7 =
typeof gInput.messages === "string" ||
typeof gOutput.messages === "string" ||
typeof gProvider.name === "string";
const toolDefs = parseToolDefinitions(isV7 ? gTool.definitions : aiPrompt.tools);
const providerMeta = parseProviderMetadata(aiResponse.providerMetadata);
const aiTelemetry = rec(ai.telemetry);
const telemetryMetaRaw = rec(aiTelemetry.metadata);
const promptMeta = rec(telemetryMetaRaw.prompt);
const promptSlug = str(promptMeta.slug);
const promptVersion = str(promptMeta.version);
const promptModel = str(promptMeta.model);
const promptLabels = str(promptMeta.labels);
const promptInput = str(promptMeta.input);
const telemetryMeta = extractTelemetryMetadata(aiTelemetry.metadata);
return {
model,
provider: str(gProvider.name) ?? str(g.system) ?? str(aiModel.provider) ?? "unknown",
operationName: str(gOperation.name) ?? str(ai.operationId) ?? "",
responseId: str(gResponse.id) || undefined,
finishReason: isV7 ? firstFinishReason(gResponse.finish_reasons) : str(aiResponse.finishReason),
serviceTier: providerMeta?.serviceTier,
resolvedProvider: providerMeta?.resolvedProvider,
toolChoice: parseToolChoice(aiPrompt.toolChoice),
toolCount: toolDefs?.length,
messageCount: isV7 ? countGenAiMessages(gInput.messages) : countMessages(aiPrompt.messages),
telemetryMetadata: telemetryMeta,
promptSlug: promptSlug || undefined,
promptVersion: promptVersion || undefined,
promptModel: promptModel || undefined,
promptLabels: promptLabels || undefined,
promptInput: promptInput || undefined,
inputTokens,
outputTokens,
totalTokens,
cachedTokens:
num(aiUsage.cachedInputTokens) ??
num(gUsage.cache_read_input_tokens) ??
num(rec(gUsage.cache_read).input_tokens),
cacheCreationTokens:
num(aiUsage.cacheCreationInputTokens) ??
num(gUsage.cache_creation_input_tokens) ??
num(rec(gUsage.cache_creation).input_tokens),
reasoningTokens: num(aiUsage.reasoningTokens) ?? num(gUsage.reasoning_tokens),
tokensPerSecond,
msToFirstChunk: num(aiResponse.msToFirstChunk),
durationMs,
inputCost: num(triggerLlm.input_cost),
outputCost: num(triggerLlm.output_cost),
totalCost: num(triggerLlm.total_cost),
cachedCost: num(triggerLlm.cached_cost),
cacheCreationCost: num(triggerLlm.cache_creation_cost),
responseText: isV7
? extractGenAiAssistantText(gOutput.messages) || undefined
: str(aiResponse.text) || undefined,
responseObject: str(aiResponse.object) || undefined,
toolDefinitions: toolDefs,
items: isV7
? buildGenAiDisplayItems(g.system_instructions, gInput.messages, gOutput.messages, toolDefs)
: buildDisplayItems(aiPrompt.messages, aiResponse.toolCalls, toolDefs),
};
}
// ---------------------------------------------------------------------------
// Message → DisplayItem transformation
// ---------------------------------------------------------------------------
type RawMessage = {
role: string;
content: unknown;
toolCallId?: string;
name?: string;
};
/**
* Build display items from prompt messages and optionally response tool calls.
* - Parses ai.prompt.messages and merges consecutive tool-call + tool-result pairs
* - If ai.response.toolCalls is present (finishReason=tool-calls), appends those too
*/
function buildDisplayItems(
messagesRaw: unknown,
responseToolCallsRaw: unknown,
toolDefs?: ToolDefinition[]
): DisplayItem[] | undefined {
const items = parseMessagesToDisplayItems(messagesRaw);
const responseToolCalls = parseResponseToolCalls(responseToolCallsRaw);
if (!items && !responseToolCalls) return undefined;
const result = items ?? [];
if (responseToolCalls && responseToolCalls.length > 0) {
result.push({ type: "tool-use", tools: responseToolCalls });
}
if (toolDefs && toolDefs.length > 0) {
const defsByName = new Map(toolDefs.map((d) => [d.name, d]));
for (const item of result) {
if (item.type === "tool-use") {
for (const tool of item.tools) {
const def = defsByName.get(tool.toolName);
if (def) {
tool.description = def.description;
tool.parametersJson = def.parametersJson;
}
}
}
}
}
return result.length > 0 ? result : undefined;
}
function parseMessagesToDisplayItems(raw: unknown): DisplayItem[] | undefined {
if (typeof raw !== "string") return undefined;
let messages: RawMessage[];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return undefined;
messages = parsed.map((item: unknown) => {
const m = rec(item);
return {
role: str(m.role) ?? "user",
content: m.content,
toolCallId: str(m.toolCallId),
name: str(m.name),
};
});
} catch {
return undefined;
}
const items: DisplayItem[] = [];
let i = 0;
while (i < messages.length) {
const msg = messages[i];
if (msg.role === "system") {
items.push({ type: "system", text: extractTextContent(msg.content) });
i++;
continue;
}
if (msg.role === "user") {
items.push({ type: "user", text: extractTextContent(msg.content) });
i++;
continue;
}
// Assistant message — check if it contains tool calls
if (msg.role === "assistant") {
const toolCalls = extractToolCalls(msg.content);
if (toolCalls.length > 0) {
// Collect subsequent tool result messages that match these tool calls
const _toolCallIds = new Set(toolCalls.map((tc) => tc.toolCallId));
let j = i + 1;
while (j < messages.length && messages[j].role === "tool") {
j++;
}
// Gather tool result messages between i+1 and j
const toolResultMsgs = messages.slice(i + 1, j);
// Build ToolUse entries by pairing calls with results
const tools: ToolUse[] = toolCalls.map((tc) => {
const resultMsg = toolResultMsgs.find((m) => {
// Match by toolCallId in the message's content parts
const results = extractToolResults(m.content);
return results.some((r) => r.toolCallId === tc.toolCallId);
});
const result = resultMsg
? extractToolResults(resultMsg.content).find((r) => r.toolCallId === tc.toolCallId)
: undefined;
return {
toolCallId: tc.toolCallId,
toolName: tc.toolName,
inputJson: JSON.stringify(tc.input, null, 2),
resultSummary: result?.summary,
resultOutput: result?.formattedOutput,
};
});
items.push({ type: "tool-use", tools });
i = j; // skip past the tool result messages
continue;
}
// Assistant message with just text
const text = extractTextContent(msg.content);
if (text) {
items.push({ type: "assistant", text });
}
i++;
continue;
}
// Skip any other message types (tool messages that weren't consumed above)
i++;
}
return items.length > 0 ? items : undefined;
}
// ---------------------------------------------------------------------------
// Response tool calls (from ai.response.toolCalls, used when finishReason=tool-calls)
// ---------------------------------------------------------------------------
/**
* Parse ai.response.toolCalls JSON string into ToolUse entries.
* These are tool calls the model requested but haven't been executed yet in this span.
*/
function parseResponseToolCalls(raw: unknown): ToolUse[] | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return undefined;
const tools: ToolUse[] = [];
for (const item of parsed) {
const tc = rec(item);
if (tc.type === "tool-call" || tc.toolName || tc.toolCallId) {
tools.push({
toolCallId: str(tc.toolCallId) ?? "",
toolName: str(tc.toolName) ?? "",
inputJson: JSON.stringify(
tc.input && typeof tc.input === "object" ? tc.input : {},
null,
2
),
});
}
}
return tools.length > 0 ? tools : undefined;
} catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// Content part extraction
// ---------------------------------------------------------------------------
function extractTextContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const texts: string[] = [];
for (const raw of content) {
const p = rec(raw);
if (p.type === "text" && typeof p.text === "string") {
texts.push(p.text);
} else if (typeof p.text === "string") {
texts.push(p.text);
}
}
return texts.join("\n");
}
type ParsedToolCall = {
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
};
function extractToolCalls(content: unknown): ParsedToolCall[] {
if (!Array.isArray(content)) return [];
const calls: ParsedToolCall[] = [];
for (const raw of content) {
const p = rec(raw);
if (p.type === "tool-call") {
calls.push({
toolCallId: str(p.toolCallId) ?? "",
toolName: str(p.toolName) ?? "",
input: p.input && typeof p.input === "object" ? (p.input as Record<string, unknown>) : {},
});
}
}
return calls;
}
type ParsedToolResult = {
toolCallId: string;
toolName: string;
summary: string;
formattedOutput: string;
};
function extractToolResults(content: unknown): ParsedToolResult[] {
if (!Array.isArray(content)) return [];
const results: ParsedToolResult[] = [];
for (const raw of content) {
const p = rec(raw);
if (p.type === "tool-result") {
const { summary, formattedOutput } = summarizeToolOutput(p.output);
results.push({
toolCallId: str(p.toolCallId) ?? "",
toolName: str(p.toolName) ?? "",
summary,
formattedOutput,
});
}
}
return results;
}
/**
* Summarize a tool output into a short label and a formatted string for display.
* Handles the AI SDK's `{ type: "json", value: { status, contentType, body, truncated } }` shape.
*/
function summarizeToolOutput(output: unknown): { summary: string; formattedOutput: string } {
if (typeof output === "string") {
return {
summary: output.length > 80 ? output.slice(0, 80) + "..." : output,
formattedOutput: output,
};
}
if (!output || typeof output !== "object") {
return { summary: "result", formattedOutput: JSON.stringify(output, null, 2) };
}
const o = output as Record<string, unknown>;
// AI SDK wraps tool results as { type: "json", value: { status, contentType, body, ... } }
if (o.type === "json" && o.value && typeof o.value === "object") {
const v = o.value as Record<string, unknown>;
const parts: string[] = [];
if (typeof v.status === "number") parts.push(`${v.status}`);
if (typeof v.contentType === "string") parts.push(v.contentType);
if (v.truncated === true) parts.push("truncated");
return {
summary: parts.length > 0 ? parts.join(" · ") : "json result",
formattedOutput: JSON.stringify(v, null, 2),
};
}
return { summary: "result", formattedOutput: JSON.stringify(output, null, 2) };
}
// ---------------------------------------------------------------------------
// Tool definitions (from ai.prompt.tools)
// ---------------------------------------------------------------------------
/**
* Parse ai.prompt.tools — after the array fix, this arrives as a JSON array string
* where each element is itself a JSON string of a tool definition.
*/
function parseToolDefinitions(raw: unknown): ToolDefinition[] | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return undefined;
const defs: ToolDefinition[] = [];
for (const item of parsed) {
// Each item is either a JSON string or already an object
const obj = typeof item === "string" ? JSON.parse(item) : item;
if (!obj || typeof obj !== "object") continue;
const o = obj as Record<string, unknown>;
const name = str(o.name);
if (!name) continue;
const schema = o.parameters ?? o.inputSchema;
defs.push({
name,
description: str(o.description),
parametersJson:
schema && typeof schema === "object" ? JSON.stringify(schema, null, 2) : undefined,
});
}
return defs.length > 0 ? defs : undefined;
} catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// Tool choice parsing
// ---------------------------------------------------------------------------
function parseToolChoice(raw: unknown): string | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw);
if (typeof parsed === "string") return parsed;
if (parsed && typeof parsed === "object") {
const obj = parsed as Record<string, unknown>;
if (typeof obj.type === "string") return obj.type;
}
return undefined;
} catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// Message count
// ---------------------------------------------------------------------------
function countMessages(raw: unknown): number | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return undefined;
return parsed.length > 0 ? parsed.length : undefined;
} catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// AI SDK 7 — @ai-sdk/otel GenAI semantic-convention shape
// ---------------------------------------------------------------------------
//
// v7 moved span emission out of `ai` core into `@ai-sdk/otel`, which emits OTel
// GenAI semantic-convention attributes instead of the v6 `ai.*` keys:
// gen_ai.input.messages JSON string: [{ role, parts: [...] }]
// gen_ai.output.messages JSON string: [{ role:"assistant", parts, finish_reason }]
// gen_ai.system_instructions system prompt (plain string, or [{ type:"text", content }])
// Message parts (per @ai-sdk/otel's convertMessagePartToSemConv):
// { type:"text" | "reasoning", content }
// { type:"tool_call", id, name, arguments }
// { type:"tool_call_response", id, response } // response already unwrapped from the AI SDK envelope
// Media / approval / custom parts are not surfaced in the display yet.
type GenAiMessage = { role: string; parts: Record<string, unknown>[]; finishReason?: string };
function parseGenAiMessages(raw: unknown): GenAiMessage[] | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return undefined;
return parsed.map((m) => {
const o = rec(m);
return {
role: str(o.role) ?? "user",
parts: Array.isArray(o.parts) ? o.parts.map(rec) : [],
finishReason: str(o.finish_reason),
};
});
} catch {
return undefined;
}
}
/** `gen_ai.response.finish_reasons` arrives as a JSON array string (e.g. `["stop"]`); take the first. */
function firstFinishReason(raw: unknown): string | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const first = parsed.find((r) => typeof r === "string");
return typeof first === "string" ? first : undefined;
}
if (typeof parsed === "string") return parsed || undefined;
} catch {
return raw || undefined;
}
return undefined;
}
function countGenAiMessages(raw: unknown): number | undefined {
const msgs = parseGenAiMessages(raw);
return msgs && msgs.length > 0 ? msgs.length : undefined;
}
/** Concatenated text of all `text` parts across the assistant output messages. */
function extractGenAiAssistantText(outputRaw: unknown): string {
const msgs = parseGenAiMessages(outputRaw);
if (!msgs) return "";
const texts: string[] = [];
for (const m of msgs) {
if (m.role !== "assistant") continue;
for (const p of m.parts) {
if (p.type === "text" && typeof p.content === "string") texts.push(p.content);
}
}
return texts.join("\n");
}
/** Plain text of a message's `text` parts (reasoning parts aren't surfaced yet). */
function genAiMessageText(parts: Record<string, unknown>[]): string {
const texts: string[] = [];
for (const p of parts) {
if (p.type === "text" && typeof p.content === "string") texts.push(p.content);
}
return texts.join("\n");
}
/** Parse `gen_ai.system_instructions` (plain string, or a JSON array of `{ type:"text", content }`). */
function parseSystemInstructions(raw: unknown): string | undefined {
if (typeof raw !== "string") return undefined;
const trimmed = raw.trim();
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
const text = parsed
.map((p) => str(rec(p).content))
.filter((t): t is string => Boolean(t))
.join("\n");
return text || undefined;
}
} catch {
// fall through to raw
}
}
return raw || undefined;
}
/**
* Build display items from the v7 GenAI message attributes: the system prompt,
* the input message history, and the assistant's output for this span. Assistant
* tool_call parts are paired with tool_call_response parts from following tool messages.
*/
function buildGenAiDisplayItems(
systemInstructionsRaw: unknown,
inputMessagesRaw: unknown,
outputMessagesRaw: unknown,
toolDefs?: ToolDefinition[]
): DisplayItem[] | undefined {
const items: DisplayItem[] = [];
const systemText = parseSystemInstructions(systemInstructionsRaw);
if (systemText) items.push({ type: "system", text: systemText });
const messages = [
...(parseGenAiMessages(inputMessagesRaw) ?? []),
...(parseGenAiMessages(outputMessagesRaw) ?? []),
];
appendGenAiMessages(items, messages);
if (toolDefs && toolDefs.length > 0) {
const defsByName = new Map(toolDefs.map((d) => [d.name, d]));
for (const item of items) {
if (item.type === "tool-use") {
for (const tool of item.tools) {
const def = defsByName.get(tool.toolName);
if (def) {
tool.description = def.description;
tool.parametersJson = def.parametersJson;
}
}
}
}
}
return items.length > 0 ? items : undefined;
}
function appendGenAiMessages(items: DisplayItem[], messages: GenAiMessage[]): void {
let i = 0;
while (i < messages.length) {
const msg = messages[i];
if (msg.role === "system") {
const text = genAiMessageText(msg.parts);
if (text) items.push({ type: "system", text });
i++;
continue;
}
if (msg.role === "user") {
const text = genAiMessageText(msg.parts);
if (text) items.push({ type: "user", text });
i++;
continue;
}
if (msg.role === "assistant") {
const text = genAiMessageText(msg.parts);
if (text) items.push({ type: "assistant", text });
const toolCalls = msg.parts.filter((p) => p.type === "tool_call");
if (toolCalls.length > 0) {
// Collect tool_call_response parts from the tool messages that follow.
const responsesById = new Map<string, unknown>();
let j = i + 1;
while (j < messages.length && messages[j].role === "tool") {
for (const p of messages[j].parts) {
if (p.type === "tool_call_response") {
const id = str(p.id);
if (id) responsesById.set(id, p.response);
}
}
j++;
}
const tools: ToolUse[] = toolCalls.map((tc) => {
const id = str(tc.id) ?? "";
let resultSummary: string | undefined;
let resultOutput: string | undefined;
if (id && responsesById.has(id)) {
const summarized = summarizeToolOutput(responsesById.get(id));
resultSummary = summarized.summary;
resultOutput = summarized.formattedOutput;
}
return {
toolCallId: id,
toolName: str(tc.name) ?? "",
inputJson: JSON.stringify(tc.arguments ?? {}, null, 2),
resultSummary,
resultOutput,
};
});
items.push({ type: "tool-use", tools });
i = j;
continue;
}
i++;
continue;
}
// tool-role messages are consumed via the assistant pairing above; skip stragglers.
i++;
}
}
@@ -0,0 +1,196 @@
import { rec, str, num, parseProviderMetadata, extractTelemetryMetadata } from "./aiHelpers";
import type { AISpanData, DisplayItem } from "./types";
/**
* Extracts structured AI data from top-level AI SDK parent spans.
*
* These spans (ai.generateText, ai.streamText, ai.generateObject, ai.streamObject)
* use `ai.*` attributes instead of `gen_ai.*`. They contain the full prompt,
* aggregated response, and total usage across all steps.
*/
export function extractAISummarySpanData(
properties: Record<string, unknown>,
durationMs: number
): AISpanData | undefined {
const ai = rec(properties.ai);
if (!ai.operationId) return undefined;
// Skip child spans that have gen_ai.* (those use extractAISpanData)
if (properties.gen_ai && typeof properties.gen_ai === "object") return undefined;
const aiModel = rec(ai.model);
const aiResponse = rec(ai.response);
const aiUsage = rec(ai.usage);
const _aiSettings = rec(ai.settings);
const _aiRequest = rec(ai.request);
const aiTelemetry = rec(ai.telemetry);
const trigger = rec(properties.trigger);
const triggerLlm = rec(trigger.llm);
const model = str(aiModel.id);
if (!model) return undefined;
const provider = str(aiModel.provider) ?? "unknown";
const operationName = str(ai.operationId) ?? "";
// Token usage
const inputTokens = num(aiUsage.inputTokens) ?? num(aiUsage.promptTokens) ?? 0;
const outputTokens = num(aiUsage.outputTokens) ?? num(aiUsage.completionTokens) ?? 0;
const totalTokens = num(aiUsage.totalTokens) ?? inputTokens + outputTokens;
const tokensPerSecond =
outputTokens > 0 && durationMs > 0
? Math.round((outputTokens / (durationMs / 1000)) * 10) / 10
: undefined;
// Provider metadata
const providerMeta = parseProviderMetadata(aiResponse.providerMetadata);
// Response ID from provider metadata
const responseId = providerMeta?.responseId;
// Telemetry metadata (prompt info, custom metadata)
const telemetryMetaRaw = rec(aiTelemetry.metadata);
const promptMeta = rec(telemetryMetaRaw.prompt);
const promptSlug = str(promptMeta.slug);
const promptVersion = str(promptMeta.version);
const promptModel = str(promptMeta.model);
const promptLabels = str(promptMeta.labels);
const promptInput = str(promptMeta.input);
const telemetryMeta = extractTelemetryMetadata(aiTelemetry.metadata);
// Parse the prompt JSON to build display items
const promptJson = str(ai.prompt);
const items = promptJson
? parsePromptToDisplayItems(promptJson, str(aiResponse.text))
: undefined;
// Count messages from the parsed prompt
let messageCount: number | undefined;
if (promptJson) {
try {
const parsed = JSON.parse(promptJson) as Record<string, unknown>;
if (parsed.messages && Array.isArray(parsed.messages)) {
messageCount = parsed.messages.length;
} else {
// system + prompt = 2 messages
messageCount = (parsed.system ? 1 : 0) + (parsed.prompt ? 1 : 0);
}
} catch {}
}
return {
model,
provider,
operationName,
responseId,
finishReason: str(aiResponse.finishReason),
serviceTier: providerMeta?.serviceTier,
resolvedProvider: providerMeta?.resolvedProvider,
toolChoice: undefined,
toolCount: undefined,
messageCount,
telemetryMetadata: telemetryMeta,
promptSlug: promptSlug || undefined,
promptVersion: promptVersion || undefined,
promptModel: promptModel || undefined,
promptLabels: promptLabels || undefined,
promptInput: promptInput || undefined,
inputTokens,
outputTokens,
totalTokens,
cachedTokens: num(aiUsage.cachedInputTokens),
cacheCreationTokens: num(aiUsage.cacheCreationInputTokens),
reasoningTokens: num(aiUsage.reasoningTokens),
tokensPerSecond,
msToFirstChunk: undefined, // Only on child doStream spans
durationMs,
inputCost: num(triggerLlm.input_cost),
outputCost: num(triggerLlm.output_cost),
totalCost: num(triggerLlm.total_cost),
cachedCost: num(triggerLlm.cached_cost),
cacheCreationCost: num(triggerLlm.cache_creation_cost),
responseText: str(aiResponse.text) || undefined,
responseObject: str(aiResponse.object) || undefined,
toolDefinitions: undefined,
items,
};
}
// ---------------------------------------------------------------------------
// Prompt parsing
// ---------------------------------------------------------------------------
/**
* Parses the `ai.prompt` JSON string into display items.
* Parent spans store the prompt as a JSON object with either:
* - { system: "...", prompt: "..." }
* - { system: "...", messages: [...] }
* - { messages: [...] }
*/
function parsePromptToDisplayItems(
promptJson: string,
responseText?: string
): DisplayItem[] | undefined {
try {
const parsed = JSON.parse(promptJson) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object") return undefined;
const items: DisplayItem[] = [];
if (typeof parsed.system === "string" && parsed.system) {
items.push({ type: "system", text: parsed.system });
}
if (typeof parsed.prompt === "string" && parsed.prompt) {
items.push({ type: "user", text: parsed.prompt });
}
if (Array.isArray(parsed.messages)) {
for (const msg of parsed.messages) {
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
const role = m.role;
const content = extractMessageContent(m.content);
if (!content) continue;
switch (role) {
case "system":
items.push({ type: "system", text: content });
break;
case "user":
items.push({ type: "user", text: content });
break;
case "assistant":
items.push({ type: "assistant", text: content });
break;
}
}
}
// Add response as assistant item if not already present
if (responseText && !items.some((i) => i.type === "assistant")) {
items.push({ type: "assistant", text: responseText });
}
return items.length > 0 ? items : undefined;
} catch {
return undefined;
}
}
function extractMessageContent(content: unknown): string | undefined {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
// Extract text parts from content array [{type: "text", text: "..."}]
return content
.filter((p): p is { type: string; text: string } => {
if (!p || typeof p !== "object") return false;
const o = p as Record<string, unknown>;
return o.type === "text" && typeof o.text === "string";
})
.map((p) => p.text)
.join("\n");
}
return undefined;
}
@@ -0,0 +1,8 @@
export { AISpanDetails } from "./AISpanDetails";
export { extractAISpanData } from "./extractAISpanData";
export { extractAISummarySpanData } from "./extractAISummarySpanData";
export { AIToolCallSpanDetails, extractAIToolCallData } from "./AIToolCallSpanDetails";
export type { AIToolCallData } from "./AIToolCallSpanDetails";
export { AIEmbedSpanDetails, extractAIEmbedData } from "./AIEmbedSpanDetails";
export type { AIEmbedData } from "./AIEmbedSpanDetails";
export type { AISpanData, DisplayItem, ToolUse } from "./types";
@@ -0,0 +1,120 @@
// ---------------------------------------------------------------------------
// Tool use (merged assistant tool-call + tool result)
// ---------------------------------------------------------------------------
export type ToolDefinition = {
name: string;
description?: string;
/** JSON schema as formatted string */
parametersJson?: string;
};
export type ToolUse = {
toolCallId: string;
toolName: string;
/** Tool description from the definition, if available */
description?: string;
/** JSON schema of the tool's parameters, pretty-printed */
parametersJson?: string;
/** Formatted input args as JSON string */
inputJson: string;
/** Short summary of the result (e.g. "200 · text/html · truncated") */
resultSummary?: string;
/** Full formatted result for display in a code block */
resultOutput?: string;
/** Sub-agent output — when the tool result is a UIMessage with parts */
subAgent?: {
parts: any[];
isStreaming: boolean;
};
};
// ---------------------------------------------------------------------------
// Display items — what the UI actually renders
// ---------------------------------------------------------------------------
/** System prompt text (collapsible) */
export type SystemItem = {
type: "system";
text: string;
};
/** User message text */
export type UserItem = {
type: "user";
text: string;
};
/** One or more tool calls with their results, grouped */
export type ToolUseItem = {
type: "tool-use";
tools: ToolUse[];
};
/** Final assistant text response */
export type AssistantItem = {
type: "assistant";
text: string;
};
export type DisplayItem = SystemItem | UserItem | ToolUseItem | AssistantItem;
// ---------------------------------------------------------------------------
// Span-level AI data
// ---------------------------------------------------------------------------
export type AISpanData = {
model: string;
provider: string;
operationName: string;
responseId?: string;
// Categorical tags
finishReason?: string;
serviceTier?: string;
/** Resolved downstream provider for gateway/openrouter spans (e.g. "xAI", "mistral") */
resolvedProvider?: string;
toolChoice?: string;
toolCount?: number;
messageCount?: number;
/** User-defined telemetry metadata (from ai.telemetry.metadata) */
telemetryMetadata?: Record<string, string>;
// Prompt metadata (from ai.telemetry.metadata.prompt)
promptSlug?: string;
promptVersion?: string;
promptModel?: string;
promptLabels?: string;
promptInput?: string;
// Token counts
inputTokens: number;
outputTokens: number;
totalTokens: number;
cachedTokens?: number;
cacheCreationTokens?: number;
reasoningTokens?: number;
// Performance
tokensPerSecond?: number;
msToFirstChunk?: number;
durationMs: number;
// Cost
inputCost?: number;
outputCost?: number;
totalCost?: number;
cachedCost?: number;
cacheCreationCost?: number;
// Response text (final assistant output)
responseText?: string;
// Structured object response (JSON) — mutually exclusive with responseText
responseObject?: string;
// Tool definitions (from ai.prompt.tools)
toolDefinitions?: ToolDefinition[];
// Display-ready message items (system, user, tool-use groups, assistant text)
items?: DisplayItem[];
};
@@ -0,0 +1,9 @@
<svg width="9" height="25" viewBox="0 0 9 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_13512_47994" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="9" height="25">
<rect width="9" height="25" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_13512_47994)">
<path d="M8.51694 0.5H10.5V24.5H8.51694C7.17088 24.5 5.94409 23.7281 5.36161 22.5146L1.69703 14.88C0.974863 13.3755 0.974863 11.6245 1.69703 10.12L5.3616 2.48544C5.94409 1.27194 7.17088 0.5 8.51694 0.5Z" fill="#1A1B1F" stroke="#272A2E" vectorEffect="non-scaling-stroke"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 598 B