269 lines
7.0 KiB
TypeScript
269 lines
7.0 KiB
TypeScript
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";
|
|
}
|
|
}
|
|
}
|