chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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 hasn’t 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 |
Reference in New Issue
Block a user