Files
2026-07-13 13:32:57 +08:00

1061 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BoltIcon, BoltSlashIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Clipboard, ClipboardCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import simplur from "simplur";
import { z } from "zod";
import { AIChatIcon } from "~/assets/icons/AIChatIcon";
import { MessageInputIcon } from "~/assets/icons/MessageInputIcon";
import { MessageOutputIcon } from "~/assets/icons/MessageOutputIcon";
import { MoveToBottomIcon } from "~/assets/icons/MoveToBottomIcon";
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
import { TextInlineIcon } from "~/assets/icons/TextInlineIcon";
import { TextWrapIcon } from "~/assets/icons/TextWrapIcon";
import { CodeBlock } from "~/components/code/CodeBlock";
import { PageBody } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Spinner } from "~/components/primitives/Spinner";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import {
SimpleTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { AgentView } from "~/components/runs/v3/agent/AgentView";
import { RunTag } from "~/components/runs/v3/RunTag";
import {
descriptionForTaskRunStatus,
TaskRunStatusCombo,
} from "~/components/runs/v3/TaskRunStatus";
import { CloseSessionDialog } from "~/components/sessions/v1/CloseSessionDialog";
import { SessionStatusCombo } from "~/components/sessions/v1/SessionStatus";
import { $replica } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useHasAdminAccess } from "~/hooks/useUser";
import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { SessionPresenter } from "~/presenters/v3/SessionPresenter.server";
import {
type StreamChunk,
useRealtimeStream,
} from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
import { requireUserId } from "~/services/session.server";
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
import { cn } from "~/utils/cn";
import { throwNotFound } from "~/utils/httpErrors";
import {
docsPath,
EnvironmentParamSchema,
v3RunPath,
v3RunsPath,
v3SessionsPath,
} from "~/utils/pathBuilder";
const ParamsSchema = EnvironmentParamSchema.extend({
sessionParam: z.string(),
});
export const meta: MetaFunction = () => {
return [{ title: `Session | Trigger.dev` }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, sessionParam } = ParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throwNotFound("Environment not found");
}
const presenter = new SessionPresenter($replica);
const session = await presenter.call({
userId,
environmentId: environment.id,
sessionParam,
projectExternalRef: project.externalRef,
environmentSlug: environment.slug,
});
if (!session) {
throw new Response("Session not found", { status: 404 });
}
return typedjson({ session });
};
export default function Page() {
const { session } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const status: SessionStatus =
session.closedAt != null
? "CLOSED"
: session.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now()
? "EXPIRED"
: "ACTIVE";
const displayId = session.externalId ?? session.friendlyId;
const sessionsPath = v3SessionsPath(organization, project, environment);
return (
<>
<NavBar>
<PageTitle
backButton={{ to: sessionsPath, text: "Sessions" }}
title={
<span className="flex items-center gap-1">
<AIChatIcon className="size-4.5 text-sessions" />
<CopyableText value={displayId} />
</span>
}
/>
<PageAccessories>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/ai-chat/sessions")}
>
Sessions docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="session-conversation" min={"300px"}>
<ConversationPane session={session} />
</ResizablePanel>
<ResizableHandle id="session-handle" />
<ResizablePanel
id="session-inspector"
min="380px"
default="420px"
className="overflow-hidden"
>
<InspectorPane session={session} status={status} />
</ResizablePanel>
</ResizablePanelGroup>
</PageBody>
</>
);
}
type LoadedSession = ReturnType<typeof useTypedLoaderData<typeof loader>>["session"];
function ConversationPane({ session }: { session: LoadedSession }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { value, replace } = useSearchParams();
const isRaw = value("raw") === "1";
const sessionId = session.agentView.sessionId;
const encodedSession = encodeURIComponent(sessionId);
const sessionResourceBase = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/sessions/${encodedSession}/realtime/v1`;
const setView = useCallback((raw: boolean) => replace({ raw: raw ? "1" : undefined }), [replace]);
return (
<div className="flex h-full max-h-full flex-col overflow-hidden bg-background-bright">
{isRaw ? (
<RawConversationView
inResourcePath={`${sessionResourceBase}/in`}
outResourcePath={`${sessionResourceBase}/out`}
isRaw={isRaw}
onChangeView={setView}
/>
) : (
<>
<ConversationUtilityBar isRaw={isRaw} onChangeView={setView} />
<div className="min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<AgentView agentView={session.agentView} />
</div>
</>
)}
</div>
);
}
function ConversationUtilityBar({
isRaw,
onChangeView,
right,
}: {
isRaw: boolean;
onChangeView: (raw: boolean) => void;
right?: React.ReactNode;
}) {
return (
<div className="flex h-9 items-center justify-between gap-3 border-b border-grid-bright px-3">
<TabContainer className="-mb-2">
<TabButton
isActive={!isRaw}
layoutId="conversation-view-mode"
onClick={() => onChangeView(false)}
>
Rendered
</TabButton>
<TabButton
isActive={isRaw}
layoutId="conversation-view-mode"
onClick={() => onChangeView(true)}
>
Raw
</TabButton>
</TabContainer>
{right}
</div>
);
}
type MergedChunk = StreamChunk & { source: "in" | "out" };
function formatInlineData(data: unknown): string {
if (typeof data === "string") return data;
const json = JSON.stringify(data);
return json ?? String(data);
}
function formatWrappedData(data: unknown): string {
if (typeof data === "string") return data;
const json = JSON.stringify(data, null, 2);
return json ?? String(data);
}
const ROW_NUMBER_COL_MIN_CH = 3;
const TIME_COL_WIDTH = "7rem";
const TYPE_COL_WIDTH = "5rem";
function RawConversationView({
inResourcePath,
outResourcePath,
isRaw,
onChangeView,
}: {
inResourcePath: string;
outResourcePath: string;
isRaw: boolean;
onChangeView: (raw: boolean) => void;
}) {
const {
chunks: inChunks,
error: inError,
isConnected: inConnected,
} = useRealtimeStream(inResourcePath);
const {
chunks: outChunks,
error: outError,
isConnected: outConnected,
} = useRealtimeStream(outResourcePath);
const merged = useMemo<MergedChunk[]>(() => {
const all: MergedChunk[] = [
...inChunks.map((c) => ({ ...c, source: "in" as const })),
...outChunks.map((c) => ({ ...c, source: "out" as const })),
];
all.sort((a, b) => a.timestamp - b.timestamp);
return all;
}, [inChunks, outChunks]);
const longestInlineMessage = useMemo(() => {
let longest = "";
for (const chunk of merged) {
const text = formatInlineData(chunk.data);
if (text.length > longest.length) longest = text;
}
return longest;
}, [merged]);
const error = inError ?? outError;
const isConnected = inConnected || outConnected;
const totalChunks = merged.length;
const scrollRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [copied, setCopied] = useState(false);
const [mouseOver, setMouseOver] = useState(false);
const [isWrapped, setIsWrapped] = useState(false);
const getCompactText = useCallback(() => {
return merged
.map((chunk) => {
const prefix = chunk.source === "in" ? "» " : "« ";
return `${prefix}${formatInlineData(chunk.data)}`;
})
.join("\n");
}, [merged]);
const onCopied = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
navigator.clipboard.writeText(getCompactText());
setCopied(true);
setTimeout(() => setCopied(false), 1500);
},
[getCompactText]
);
// Observer + scroll listener attach once on mount. Earlier this effect
// depended on `merged.length` and tore down / re-attached on every
// streaming chunk, thrashing CPU on hot sessions.
useEffect(() => {
const bottomElement = bottomRef.current;
const scrollElement = scrollRef.current;
if (!bottomElement || !scrollElement) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry) setIsAtBottom(entry.isIntersecting);
},
{ root: scrollElement, threshold: 0.1, rootMargin: "0px" }
);
observer.observe(bottomElement);
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
const handleScroll = () => {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const scrollBottom = scrollElement.scrollTop + scrollElement.clientHeight;
setIsAtBottom(scrollElement.scrollHeight - scrollBottom < 50);
}, 100);
};
scrollElement.addEventListener("scroll", handleScroll);
// Initial state — match the post-mount scroll position.
const initialScrollBottom = scrollElement.scrollTop + scrollElement.clientHeight;
setIsAtBottom(scrollElement.scrollHeight - initialScrollBottom < 50);
return () => {
observer.disconnect();
scrollElement.removeEventListener("scroll", handleScroll);
if (scrollTimeout) clearTimeout(scrollTimeout);
};
}, []);
// Stick-to-bottom on new content. Defer the scroll write to the next
// animation frame so the virtualizer's layout effect has run and
// `scrollHeight` reflects the updated content. Without rAF, the write
// races the virtualizer's `getTotalSize()` update and the user gets
// "stuck half a row up" while streaming.
useEffect(() => {
if (!isAtBottom || !scrollRef.current) return;
const el = scrollRef.current;
const raf = requestAnimationFrame(() => {
const currentScrollLeft = el.scrollLeft;
el.scrollTop = el.scrollHeight;
el.scrollLeft = currentScrollLeft;
});
return () => cancelAnimationFrame(raf);
}, [merged, isAtBottom]);
const rowVirtualizer = useVirtualizer({
count: merged.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 28,
overscan: 8,
});
const rowNumberWidthCh = Math.max(ROW_NUMBER_COL_MIN_CH, merged.length.toString().length);
const controls = (
<div className="flex items-center gap-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
{isConnected ? (
<BoltIcon className="size-3.5 animate-pulse cursor-default text-success" />
) : (
<BoltSlashIcon className="size-3.5 cursor-default text-text-dimmed" />
)}
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{isConnected ? "Connected" : "Disconnected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Paragraph variant="small" className="mb-0 whitespace-nowrap">
{simplur`${totalChunks} chunk[|s]`}
</Paragraph>
<TooltipProvider>
<Tooltip
open={totalChunks === 0 ? false : copied || mouseOver || undefined}
disableHoverableContent
>
<TooltipTrigger
disabled={totalChunks === 0}
onClick={onCopied}
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
className={cn(
"transition-colors duration-100 focus-custom",
totalChunks === 0
? "cursor-not-allowed opacity-50"
: copied
? "text-success hover:cursor-pointer"
: "text-text-dimmed hover:cursor-pointer hover:text-text-bright"
)}
>
{copied ? <ClipboardCheck className="size-4" /> : <Clipboard className="size-4" />}
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{copied ? "Copied" : "Copy"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger
onClick={() => setIsWrapped((w) => !w)}
className="text-text-dimmed transition-colors focus-custom hover:cursor-pointer hover:text-text-bright"
>
{isWrapped ? (
<TextInlineIcon className="size-4" />
) : (
<TextWrapIcon className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{isWrapped ? "Show messages on one line" : "Wrap messages"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip open={totalChunks === 0 ? false : undefined} disableHoverableContent>
<TooltipTrigger
disabled={totalChunks === 0}
onClick={() => {
if (isAtBottom) {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
} else {
bottomRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
inline: "nearest",
});
}
}}
className={cn(
"text-text-dimmed transition-colors focus-custom",
totalChunks === 0
? "cursor-not-allowed opacity-50"
: "hover:cursor-pointer hover:text-text-bright"
)}
>
{isAtBottom ? (
<MoveToTopIcon className="size-4" />
) : (
<MoveToBottomIcon className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{isAtBottom ? "Scroll to top" : "Scroll to bottom"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
return (
<>
<ConversationUtilityBar isRaw={isRaw} onChangeView={onChangeView} right={controls} />
<div className="flex min-h-0 flex-1 flex-col bg-background-deep">
<div
ref={scrollRef}
className="flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
<div
className="font-mono text-xs leading-tight"
style={{ width: isWrapped ? "100%" : "max-content", minWidth: "100%" }}
>
<StreamColumnHeader
rowNumberWidthCh={rowNumberWidthCh}
timeColWidth={TIME_COL_WIDTH}
typeColWidth={TYPE_COL_WIDTH}
className="sticky top-0 z-10"
/>
{error && (
<div className="border-b border-error/20 bg-error/10 p-3">
<Paragraph variant="small" className="mb-0 text-error">
Error: {error.message}
</Paragraph>
</div>
)}
{merged.length === 0 && !error && (
<div className="flex h-full items-center justify-center py-6">
{isConnected ? (
<div className="flex items-center gap-2">
<Spinner />
<Paragraph variant="small" className="mb-0 text-text-dimmed">
Waiting for data
</Paragraph>
</div>
) : (
<Paragraph variant="small" className="mb-0 text-text-dimmed">
No data received
</Paragraph>
)}
</div>
)}
{merged.length > 0 && (
<>
{!isWrapped && longestInlineMessage && (
<div
aria-hidden
className="invisible flex"
style={{ height: 0, overflow: "hidden" }}
>
<div className="flex-none" style={{ width: `${rowNumberWidthCh}ch` }} />
<div className="flex-none px-3" style={{ width: TIME_COL_WIDTH }} />
<div className="flex-none px-3" style={{ width: TYPE_COL_WIDTH }} />
<div className="whitespace-nowrap px-3">{longestInlineMessage}</div>
</div>
)}
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
minWidth: "100%",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
const chunk = merged[virtualItem.index];
return (
<MergedStreamRow
key={virtualItem.key}
chunk={chunk}
lineNumber={virtualItem.index + 1}
rowNumberWidthCh={rowNumberWidthCh}
timeColWidth={TIME_COL_WIDTH}
typeColWidth={TYPE_COL_WIDTH}
isWrapped={isWrapped}
start={virtualItem.start}
measure={(el) => rowVirtualizer.measureElement(el)}
index={virtualItem.index}
/>
);
})}
<div
ref={bottomRef}
className="h-px"
style={{
position: "absolute",
top: `${rowVirtualizer.getTotalSize()}px`,
}}
/>
</div>
</>
)}
</div>
</div>
</div>
</>
);
}
function StreamColumnHeader({
rowNumberWidthCh,
timeColWidth,
typeColWidth,
className,
}: {
rowNumberWidthCh: number;
timeColWidth: string;
typeColWidth: string;
className?: string;
}) {
return (
<div
className={cn(
"relative flex h-9 select-none items-center bg-background-deep font-sans text-sm font-medium leading-normal text-text-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright",
className
)}
>
<div className="flex-none" style={{ width: `${rowNumberWidthCh}ch` }} />
<div className="flex-none px-3" style={{ width: timeColWidth }}>
Time
</div>
<div className="flex-none px-3" style={{ width: typeColWidth }}>
Type
</div>
<div className="min-w-0 flex-1 px-3">Message</div>
</div>
);
}
function MergedStreamRow({
chunk,
lineNumber,
rowNumberWidthCh,
timeColWidth,
typeColWidth,
isWrapped,
start,
measure,
index,
}: {
chunk: MergedChunk;
lineNumber: number;
rowNumberWidthCh: number;
timeColWidth: string;
typeColWidth: string;
isWrapped: boolean;
start: number;
measure: (el: HTMLDivElement | null) => void;
index: number;
}) {
const wrappedData = formatWrappedData(chunk.data);
const inlineData = formatInlineData(chunk.data);
const date = new Date(chunk.timestamp);
const timeString = date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const milliseconds = date.getMilliseconds().toString().padStart(3, "0");
const timestamp = `${timeString}.${milliseconds}`;
const isInput = chunk.source === "in";
return (
<div
ref={measure}
data-index={index}
className="group flex items-start py-1 hover:bg-background-bright"
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
transform: `translateY(${start}px)`,
}}
>
<div
className="flex-none select-none pl-2 text-right text-text-faint"
style={{ width: `${rowNumberWidthCh}ch` }}
>
{lineNumber}
</div>
<div className="flex-none select-none pl-3 text-text-faint" style={{ width: timeColWidth }}>
{timestamp}
</div>
<div className="flex-none px-3" style={{ width: typeColWidth }}>
<span
className={cn(
"inline-flex items-center gap-1",
isInput ? "text-success" : "text-blue-500"
)}
>
{isInput ? (
<MessageInputIcon className="size-3.5" />
) : (
<MessageOutputIcon className="size-3.5" />
)}
{isInput ? "Input" : "Output"}
</span>
</div>
<div
className={cn(
"min-w-0 flex-1 px-3 text-text-bright",
isWrapped ? "whitespace-pre-wrap wrap-break-word" : "whitespace-nowrap"
)}
>
{isWrapped ? wrappedData : inlineData}
</div>
</div>
);
}
function InspectorPane({ session, status }: { session: LoadedSession; status: SessionStatus }) {
const { value, replace } = useSearchParams();
const tab = value("tab") ?? "overview";
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const displayId = session.externalId ?? session.friendlyId;
const allRunsPath = v3RunsPath(organization, project, environment, {
tags: [`chat:${displayId}`],
});
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr] overflow-hidden bg-background-bright">
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
<div className="flex items-center gap-2 overflow-x-hidden">
<span className="truncate font-mono text-sm text-text-dimmed">{session.friendlyId}</span>
</div>
</div>
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<TabContainer>
<TabButton
isActive={tab === "overview"}
layoutId="session-inspector"
onClick={() => replace({ tab: "overview" })}
shortcut={{ key: "o" }}
>
Overview
</TabButton>
<TabButton
isActive={tab === "runs"}
layoutId="session-inspector"
onClick={() => replace({ tab: "runs" })}
shortcut={{ key: "r" }}
>
Runs
</TabButton>
<TabButton
isActive={tab === "metadata"}
layoutId="session-inspector"
onClick={() => replace({ tab: "metadata" })}
shortcut={{ key: "m" }}
>
Metadata
</TabButton>
</TabContainer>
</div>
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
{tab === "overview" ? (
<OverviewTab session={session} status={status} />
) : tab === "runs" ? (
<RunsTab session={session} status={status} allRunsPath={allRunsPath} />
) : (
<MetadataTab session={session} />
)}
</div>
</div>
);
}
function OverviewTab({ session, status }: { session: LoadedSession; status: SessionStatus }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const isAdmin = useHasAdminAccess();
const sessionsPath = v3SessionsPath(organization, project, environment);
return (
<div className="flex flex-col gap-4">
<Property.Table>
<div className="flex items-start justify-between gap-3">
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<SessionStatusCombo status={status} />
</Property.Value>
</Property.Item>
{status === "ACTIVE" && (
<Dialog key={`close-${session.friendlyId}`}>
<DialogTrigger asChild>
<Button variant="danger/small">Close session</Button>
</DialogTrigger>
<CloseSessionDialog
sessionParam={session.friendlyId}
environmentId={environment.id}
redirectPath={`${sessionsPath}/${session.friendlyId}`}
/>
</Dialog>
)}
</div>
<Property.Item>
<Property.Label>Friendly ID</Property.Label>
<Property.Value>
<CopyableText value={session.friendlyId} className="font-mono text-sm" />
</Property.Value>
</Property.Item>
{session.externalId ? (
<Property.Item>
<Property.Label>External ID</Property.Label>
<Property.Value>
<CopyableText value={session.externalId} className="font-mono text-sm" />
</Property.Value>
</Property.Item>
) : null}
<Property.Item>
<Property.Label>Type</Property.Label>
<Property.Value>
<span className="font-mono text-sm">{session.type}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Agent ID</Property.Label>
<Property.Value>
<span className="font-mono text-sm">{session.taskIdentifier}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Test</Property.Label>
<Property.Value>
<span className="sr-only">{session.isTest ? "Yes" : "No"}</span>
{session.isTest ? (
<CheckIcon aria-hidden className="size-4 text-text-dimmed" />
) : (
<span aria-hidden className="text-text-dimmed">
</span>
)}
</Property.Value>
</Property.Item>
{session.currentRun ? (
<Property.Item>
<Property.Label>Current run</Property.Label>
<Property.Value>
<span className="flex flex-col gap-0.5">
<TextLink
to={v3RunPath(organization, project, environment, {
friendlyId: session.currentRun.friendlyId,
})}
className="font-mono text-sm"
>
{session.currentRun.friendlyId}
</TextLink>
<SimpleTooltip
button={<TaskRunStatusCombo status={session.currentRun.status} />}
content={descriptionForTaskRunStatus(session.currentRun.status)}
disableHoverableContent
buttonClassName="w-fit"
/>
</span>
</Property.Value>
</Property.Item>
) : null}
<Property.Item>
<Property.Label>Tags</Property.Label>
<Property.Value>
{session.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{session.tags.map((tag) => (
<RunTag key={tag} tag={tag} />
))}
</div>
) : (
<span className="text-text-dimmed"></span>
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Created</Property.Label>
<Property.Value>
<DateTime date={session.createdAt} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Updated</Property.Label>
<Property.Value>
<DateTime date={session.updatedAt} />
</Property.Value>
</Property.Item>
{session.expiresAt ? (
<Property.Item>
<Property.Label>
{new Date(session.expiresAt).getTime() < Date.now() ? "Expired" : "Expires"}
</Property.Label>
<Property.Value>
<DateTime date={session.expiresAt} />
</Property.Value>
</Property.Item>
) : null}
{session.closedAt ? (
<Property.Item>
<Property.Label>Closed</Property.Label>
<Property.Value>
<DateTime date={session.closedAt} />
</Property.Value>
</Property.Item>
) : null}
{session.closedReason ? (
<Property.Item>
<Property.Label>Close reason</Property.Label>
<Property.Value>
<span className="text-xs">{session.closedReason}</span>
</Property.Value>
</Property.Item>
) : null}
</Property.Table>
<CodeBlock
code={JSON.stringify(session.triggerConfig, null, 2)}
language="json"
rowTitle="Trigger config"
maxLines={20}
showLineNumbers={false}
showTextWrapping
/>
{isAdmin && (
<div className="border-t border-yellow-500/50 pt-2">
<Paragraph spacing variant="small" className="text-yellow-500">
Admin only
</Paragraph>
<Property.Table>
<Property.Item>
<Property.Label>Session ID</Property.Label>
<Property.Value>
<span className="font-mono text-xs">{session.id}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Stream basin</Property.Label>
<Property.Value>
<span className="font-mono text-xs">{session.streamBasinName ?? "(global)"}</span>
</Property.Value>
</Property.Item>
</Property.Table>
</div>
)}
</div>
);
}
function MetadataTab({ session }: { session: LoadedSession }) {
if (session.metadata == null) {
return <Paragraph variant="small/dimmed">No metadata.</Paragraph>;
}
const json = JSON.stringify(session.metadata, null, 2);
return <CodeBlock code={json} language="json" showLineNumbers={false} showTextWrapping />;
}
function RunsTab({
session,
status,
allRunsPath,
}: {
session: LoadedSession;
status: SessionStatus;
allRunsPath: string;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
if (session.runs.length === 0) {
return <Paragraph variant="small/dimmed">No runs yet.</Paragraph>;
}
return (
<div className="flex flex-col">
<TimelineRow lineVariant="dashed">
<span className="flex items-center gap-1.5 text-sm font-medium text-text-bright">
<span>Session:</span>
<SessionStatusCombo status={status} className="text-sm" />
</span>
<span className="text-xs text-text-dimmed">{sessionStatusBlurb(status)}</span>
<LinkButton variant="secondary/small" to={allRunsPath} className="mt-1 w-fit">
View all runs
</LinkButton>
</TimelineRow>
{session.runs.map((entry, idx) => {
const isLast = idx === session.runs.length - 1;
const runPath = entry.run
? v3RunPath(organization, project, environment, {
friendlyId: entry.run.friendlyId,
})
: undefined;
return (
<TimelineRow key={entry.id} isLast={isLast}>
<span className="text-sm font-medium text-text-bright">
<span className="capitalize">{entry.reason}</span> run
</span>
<span className="text-xs text-text-dimmed">
<DateTime date={entry.triggeredAt} />
</span>
{entry.run && runPath ? (
<>
<SimpleTooltip
asChild
buttonClassName="w-fit self-start"
button={
<TextLink to={runPath} className="font-mono text-sm">
<CopyableText
value={entry.run.friendlyId}
copyValue={entry.run.friendlyId}
asChild
/>
</TextLink>
}
content="Jump to run"
disableHoverableContent
/>
<TaskRunStatusCombo status={entry.run.status} className="text-sm" />
</>
) : (
<span className="text-text-dimmed"></span>
)}
</TimelineRow>
);
})}
</div>
);
}
function sessionStatusBlurb(status: SessionStatus): string {
switch (status) {
case "ACTIVE":
return "Accepting new runs";
case "CLOSED":
return "No longer accepting new runs";
case "EXPIRED":
return "Expired without being closed";
}
}
function TimelineRow({
children,
isLast,
lineVariant = "solid",
}: {
children: React.ReactNode;
isLast?: boolean;
lineVariant?: "solid" | "dashed";
}) {
return (
<div className="flex gap-3">
<div className="flex flex-none flex-col items-center">
<div className="my-1.5 size-2.5 shrink-0 rounded-full border border-border-brightest" />
{!isLast &&
(lineVariant === "dashed" ? (
<div className="flex-1 border-l border-dashed border-border-bright" />
) : (
<div className="w-px flex-1 bg-surface-control" />
))}
</div>
<div className={cn("flex min-w-0 flex-1 flex-col gap-0.5", !isLast && "pb-4")}>
{children}
</div>
</div>
);
}