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(); 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 ( <> } /> Sessions docs ); } type LoadedSession = ReturnType>["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 (
{isRaw ? ( ) : ( <>
)}
); } function ConversationUtilityBar({ isRaw, onChangeView, right, }: { isRaw: boolean; onChangeView: (raw: boolean) => void; right?: React.ReactNode; }) { return (
onChangeView(false)} > Rendered onChangeView(true)} > Raw {right}
); } 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(() => { 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(null); const bottomRef = useRef(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) => { 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 | 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 = (
{isConnected ? ( ) : ( )} {isConnected ? "Connected" : "Disconnected"} {simplur`${totalChunks} chunk[|s]`} 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 ? : } {copied ? "Copied" : "Copy"} setIsWrapped((w) => !w)} className="text-text-dimmed transition-colors focus-custom hover:cursor-pointer hover:text-text-bright" > {isWrapped ? ( ) : ( )} {isWrapped ? "Show messages on one line" : "Wrap messages"} { 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 ? ( ) : ( )} {isAtBottom ? "Scroll to top" : "Scroll to bottom"}
); return ( <>
{error && (
Error: {error.message}
)} {merged.length === 0 && !error && (
{isConnected ? (
Waiting for data…
) : ( No data received )}
)} {merged.length > 0 && ( <> {!isWrapped && longestInlineMessage && (
{longestInlineMessage}
)}
{rowVirtualizer.getVirtualItems().map((virtualItem) => { const chunk = merged[virtualItem.index]; return ( rowVirtualizer.measureElement(el)} index={virtualItem.index} /> ); })}
)}
); } function StreamColumnHeader({ rowNumberWidthCh, timeColWidth, typeColWidth, className, }: { rowNumberWidthCh: number; timeColWidth: string; typeColWidth: string; className?: string; }) { return (
Time
Type
Message
); } 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 (
{lineNumber}
{timestamp}
{isInput ? ( ) : ( )} {isInput ? "Input" : "Output"}
{isWrapped ? wrappedData : inlineData}
); } 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 (
{session.friendlyId}
replace({ tab: "overview" })} shortcut={{ key: "o" }} > Overview replace({ tab: "runs" })} shortcut={{ key: "r" }} > Runs replace({ tab: "metadata" })} shortcut={{ key: "m" }} > Metadata
{tab === "overview" ? ( ) : tab === "runs" ? ( ) : ( )}
); } 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 (
Status {status === "ACTIVE" && ( )}
Friendly ID {session.externalId ? ( External ID ) : null} Type {session.type} Agent ID {session.taskIdentifier} Test {session.isTest ? "Yes" : "No"} {session.isTest ? ( ) : ( )} {session.currentRun ? ( Current run {session.currentRun.friendlyId} } content={descriptionForTaskRunStatus(session.currentRun.status)} disableHoverableContent buttonClassName="w-fit" /> ) : null} Tags {session.tags.length > 0 ? (
{session.tags.map((tag) => ( ))}
) : ( )}
Created Updated {session.expiresAt ? ( {new Date(session.expiresAt).getTime() < Date.now() ? "Expired" : "Expires"} ) : null} {session.closedAt ? ( Closed ) : null} {session.closedReason ? ( Close reason {session.closedReason} ) : null}
{isAdmin && (
Admin only Session ID {session.id} Stream basin {session.streamBasinName ?? "(global)"}
)}
); } function MetadataTab({ session }: { session: LoadedSession }) { if (session.metadata == null) { return No metadata.; } const json = JSON.stringify(session.metadata, null, 2); return ; } 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 No runs yet.; } return (
Session: {sessionStatusBlurb(status)} View all runs {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 ( {entry.reason} run {entry.run && runPath ? ( <> } content="Jump to run" disableHoverableContent /> ) : ( )} ); })}
); } 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 (
{!isLast && (lineVariant === "dashed" ? (
) : (
))}
{children}
); }