import type { UIMessage } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react"; import { BoltIcon, CheckIcon, StopIcon } from "@heroicons/react/20/solid"; import { ClipboardIcon, TrashIcon } from "@heroicons/react/24/outline"; import { type MetaFunction } from "@remix-run/node"; import { Link, useFetcher, useNavigate, useRouteLoaderData } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { generateJWT as internal_generateJWT, MachinePresetName } from "@trigger.dev/core/v3"; import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; import { useCallback, useEffect, useRef, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon"; import { PlusIcon } from "~/assets/icons/PlusIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { JSONEditor } from "~/components/code/JSONEditor"; import { Button } from "~/components/primitives/Buttons"; import { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger, } from "~/components/primitives/ClientTabs"; import { DateTime } from "~/components/primitives/DateTime"; import { DurationPicker } from "~/components/primitives/DurationPicker"; import { Header3 } from "~/components/primitives/Headers"; 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 { Popover, PopoverContent, PopoverMenuItem, PopoverVerticalEllipseTrigger, } from "~/components/primitives/Popover"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "~/components/primitives/Resizable"; import { Select, SelectItem } from "~/components/primitives/Select"; import { Spinner } from "~/components/primitives/Spinner"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { MessageBubble } from "~/components/runs/v3/agent/AgentMessageView"; import { RunTagInput } from "~/components/runs/v3/RunTagInput"; import { env as serverEnv } from "~/env.server"; import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import type { PlaygroundConversation } from "~/presenters/v3/PlaygroundPresenter.server"; import { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server"; import { AIPayloadTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent"; import { SchemaTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent"; import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [{ title: "Playground | Trigger.dev" }]; }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); const agentSlug = params.agentParam; if (!agentSlug) { throw new Response(undefined, { status: 404, statusText: "Agent not specified" }); } const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Response(undefined, { status: 404, statusText: "Project not found" }); } const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) { throw new Response(undefined, { status: 404, statusText: "Environment not found" }); } const agent = await playgroundPresenter.getAgent({ environmentId: environment.id, environmentType: environment.type, agentSlug, }); if (!agent) { throw new Response(undefined, { status: 404, statusText: "Agent not found" }); } const agentConfig = agent.config as { type?: string } | null; const apiOrigin = serverEnv.API_ORIGIN || serverEnv.LOGIN_ORIGIN || "http://localhost:3030"; const recentConversations = await playgroundPresenter.getRecentConversations({ environmentId: environment.id, agentSlug, userId, }); // Check for ?conversation= param to resume an existing conversation const url = new URL(request.url); const conversationId = url.searchParams.get("conversation"); let activeConversation: { chatId: string; runFriendlyId: string | null; publicAccessToken: string | null; clientData: unknown; messages: unknown; lastEventId: string | null; } | null = null; if (conversationId) { const conv = recentConversations.find((c) => c.id === conversationId); if (conv) { let jwt: string | null = null; if (conv.isActive && conv.runFriendlyId) { jwt = await internal_generateJWT({ secretKey: extractJwtSigningSecretKey(environment), payload: { sub: environment.id, pub: true, scopes: [`read:runs:${conv.runFriendlyId}`, `write:inputStreams:${conv.runFriendlyId}`], }, expirationTime: "1h", }); } activeConversation = { chatId: conv.chatId, runFriendlyId: conv.runFriendlyId, publicAccessToken: jwt, clientData: conv.clientData, messages: conv.messages, lastEventId: conv.lastEventId, }; } } return typedjson({ agent: { slug: agent.slug, filePath: agent.filePath, type: agentConfig?.type ?? "unknown", clientDataSchema: agent.payloadSchema ?? null, }, apiOrigin, recentConversations, activeConversation, }); }; export default function PlaygroundAgentPage() { const { agent, activeConversation } = useTypedLoaderData(); // Key on agent slug + conversation chatId so React remounts all stateful // children when switching agents or navigating between conversations. // Without the agent slug, switching agents keeps key="new" and React // reuses the component — useState initializers don't re-run. const conversationKey = `${agent.slug}:${activeConversation?.chatId ?? "new"}`; return ; } const PARENT_ROUTE_ID = "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground"; function PlaygroundChat() { const { agent, apiOrigin, recentConversations, activeConversation } = useTypedLoaderData(); const parentData = useRouteLoaderData(PARENT_ROUTE_ID) as | { agents: Array<{ slug: string }>; versions: string[]; regions: Array<{ id: string; name: string; description?: string; isDefault: boolean; }>; isDev: boolean; } | undefined; const versions = parentData?.versions ?? []; const regions = parentData?.regions ?? []; const isDev = parentData?.isDev ?? false; const defaultRegion = regions.find((r) => r.isDefault); const navigate = useNavigate(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const [conversationId, setConversationId] = useState(() => activeConversation ? (recentConversations.find((c) => c.chatId === activeConversation.chatId)?.id ?? null) : null ); const [chatId, _setChatId] = useState(() => activeConversation?.chatId ?? crypto.randomUUID()); const [clientDataJson, setClientDataJson] = useState(() => activeConversation?.clientData ? JSON.stringify(activeConversation.clientData, null, 2) : "{}" ); const clientDataJsonRef = useRef(clientDataJson); clientDataJsonRef.current = clientDataJson; const [machine, setMachine] = useState(undefined); const [tags, setTags] = useState([]); const [maxAttempts, setMaxAttempts] = useState(undefined); const [maxDuration, setMaxDuration] = useState(undefined); const [version, setVersion] = useState(undefined); const [region, setRegion] = useState(() => isDev ? undefined : defaultRegion?.name ); const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/playground/action`; // Server-side `start` via Remix action — atomically creates the // backing Session for `chatId` and triggers the first run, returns // the session-scoped PAT. Idempotent: called on initial use AND on // 401, so the same code path serves both first-run and PAT renewal. const startSession = useCallback(async (): Promise => { const formData = new FormData(); formData.set("intent", "start"); formData.set("agentSlug", agent.slug); formData.set("chatId", chatId); formData.set("clientData", clientDataJsonRef.current); if (tags.length > 0) formData.set("tags", tags.join(",")); if (machine) formData.set("machine", machine); if (maxAttempts) formData.set("maxAttempts", String(maxAttempts)); if (maxDuration) formData.set("maxDuration", String(maxDuration)); if (version) formData.set("version", version); if (region) formData.set("region", region); const response = await fetch(actionPath, { method: "POST", body: formData }); const data = (await response.json()) as { runId?: string; publicAccessToken?: string; conversationId?: string; error?: string; }; if (!response.ok || !data.publicAccessToken) { throw new Error(data.error ?? "Failed to start chat session"); } if (data.conversationId) { setConversationId(data.conversationId); } return data.publicAccessToken; }, [actionPath, agent.slug, chatId, tags, machine, maxAttempts, maxDuration, version, region]); // Same-origin resource routes: use the page origin, not apiOrigin, so in/append // doesn't go cross-origin (CORS preflight) when API_ORIGIN != APP_ORIGIN. const origin = typeof window !== "undefined" ? window.location.origin : apiOrigin; const playgroundBaseURL = `${origin}/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/playground`; // The transport is constructed once (guarded ref below); reading // `startSession` directly there would freeze its closure to the // first render's sidebar values, so subsequent edits to tags / // machine / maxAttempts / maxDuration / version / region would be // silently ignored on the first send. Mirror the `clientDataJsonRef` // pattern so the transport always calls the latest `startSession`. const startSessionRef = useRef(startSession); startSessionRef.current = startSession; // Create TriggerChatTransport directly (not via useTriggerChatTransport hook // to avoid React version mismatch between SDK and webapp) const transportRef = useRef(null); if (transportRef.current === null) { transportRef.current = new TriggerChatTransport({ task: agent.slug, // The Remix action is idempotent on `(env, externalId)` and // returns a fresh session PAT every time, so it serves both // first-run create and PAT renewal. `startSession` runs on // `transport.preload(chatId)` and lazily on the first // `sendMessage`; `accessToken` runs on a 401/403 from any // session-PAT-authed request. Wiring the same call to both // keeps the Preload button working without a separate refresh // route. startSession: async () => ({ publicAccessToken: await startSessionRef.current() }), accessToken: () => startSessionRef.current(), baseURL: playgroundBaseURL, // Use safeParseJson so a mid-edit invalid JSON state in the editor // doesn't throw and crash the component during transport construction. clientData: safeParseJson(clientDataJson), ...(activeConversation?.publicAccessToken ? { sessions: { [activeConversation.chatId]: { publicAccessToken: activeConversation.publicAccessToken, lastEventId: activeConversation.lastEventId ?? undefined, }, }, } : {}), }); } const transport = transportRef.current; // Keep the transport's `defaultMetadata` in sync with the JSON editor. // Without this the transport uses the value captured at construction for // every per-turn metadata merge, even after the user edits the JSON. // `startSession` reads from `clientDataJsonRef.current` directly so session // creation is unaffected — this only fixes the per-turn metadata path. useEffect(() => { // JSONEditor fires onChange on every keystroke — intermediate values // like `{"key":` are syntactically invalid. `safeParseJson` returns // `{}` on parse failure so the next valid keystroke lands the update // without crashing the component mid-edit. transport.setClientData(safeParseJson(clientDataJson)); }, [clientDataJson, transport]); // Initial messages from persisted conversation (for resume) const initialMessages = activeConversation?.messages ? (activeConversation.messages as UIMessage[]) : []; // Track the initial message count so we only save after genuinely new turns // (not during resume replay which re-fires onFinish for replayed turns) const initialMessageCountRef = useRef(initialMessages?.length ?? 0); // Save messages after each turn completes const saveMessages = useCallback( (allMessages: UIMessage[]) => { // Skip saves during resume replay — only save when we have more messages than we started with if (allMessages.length <= initialMessageCountRef.current) return; const currentSession = transport.getSession(chatId); const lastEventId = currentSession?.lastEventId; const formData = new FormData(); formData.set("intent", "save"); formData.set("agentSlug", agent.slug); formData.set("chatId", chatId); formData.set("messages", JSON.stringify(allMessages)); if (lastEventId) formData.set("lastEventId", lastEventId); // Fire and forget fetch(actionPath, { method: "POST", body: formData }).catch(() => {}); // Update the baseline so subsequent saves work correctly initialMessageCountRef.current = allMessages.length; }, [chatId, agent.slug, actionPath, transport] ); // useChat from AI SDK — handles message accumulation, streaming, stop const { messages, sendMessage, stop, status, error } = useChat({ id: chatId, messages: initialMessages, transport, onFinish: ({ messages: allMessages }) => { saveMessages(allMessages); }, }); const isStreaming = status === "streaming"; const isSubmitted = status === "submitted"; // Sticky-bottom auto-scroll for the messages list. The hook walks up to // the surrounding `overflow-y-auto` panel and follows the conversation // as new chunks stream in — pauses if you scroll up to read history, // resumes when you scroll back into the bottom band. Same behavior as // the run-inspector Agent tab. const messagesRootRef = useAutoScrollToBottom([messages, isSubmitted]); // Pending messages — steering during streaming const pending = usePlaygroundPendingMessages({ transport, chatId, status, messages, sendMessage, metadata: safeParseJson(clientDataJson), }); const [input, setInput] = useState(""); const [preloading, setPreloading] = useState(false); const [preloaded, setPreloaded] = useState(false); const inputRef = useRef(null); // Focus on mount; refocus when the state-1 ↔ state-2 transition remounts the textarea. const isEmpty = messages.length === 0; useEffect(() => { inputRef.current?.focus(); }, [isEmpty]); const session = transport.getSession(chatId); const handlePreload = useCallback(async () => { setPreloading(true); try { await transport.preload(chatId); setPreloaded(true); inputRef.current?.focus(); } finally { setPreloading(false); } }, [transport, chatId]); const handleNewConversation = useCallback(() => { // Navigate without ?conversation= so the loader returns activeConversation=null // and the key changes to "new", causing a full remount with fresh state. navigate(window.location.pathname); }, [navigate]); const handleDeleteConversation = useCallback(async () => { if (!conversationId) return; const formData = new FormData(); formData.set("intent", "delete"); formData.set("agentSlug", agent.slug); formData.set("deleteConversationId", conversationId); await fetch(actionPath, { method: "POST", body: formData }); handleNewConversation(); }, [conversationId, agent.slug, actionPath, handleNewConversation]); const handleSend = useCallback(() => { const trimmed = input.trim(); if (!trimmed) return; setInput(""); // steer() handles both cases: sends via input stream during streaming, // or sends as a normal message when ready pending.steer(trimmed); // Keep focus after the state-1 → state-2 remount completes. requestAnimationFrame(() => inputRef.current?.focus()); }, [input, pending]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend] ); return (
{/* Header — floats over the scroll area so messages can scroll behind it */}
0} hasConversation={conversationId !== null} messages={messages} onDeleteConversation={handleDeleteConversation} onNewConversation={handleNewConversation} />
{/* Scroll container is always mounted — useAutoScrollToBottom caches its container on mount and won't refind it across remounts. */}
{messages.length === 0 ? (

Type a message to start testing{" "} {agent.slug}

Press Enter to send, Shift+Enter for new line } />
) : ( <> {messages.map((msg) => ( ))} {isSubmitted && (
Thinking…
)} )}
{/* Error */} {error && messages.length > 0 && (
{error.message}
)} {messages.length > 0 && (
Stop ) : undefined } footer={ Press Enter to send, Shift+Enter for new line } /> {pending.pending.length > 0 && (
{pending.pending.map((msg) => (
{msg.mode === "steering" ? "Steering" : "Queued"} {msg.text} {msg.injected && Injected}
))}
)} {isStreaming && (
Send a steering message to guide the agent between tool calls
)}
)}
clientDataJsonRef.current} clientDataSchema={agent.clientDataSchema} agentSlug={agent.slug} machine={machine} onMachineChange={setMachine} tags={tags} onTagsChange={setTags} maxAttempts={maxAttempts} onMaxAttemptsChange={setMaxAttempts} maxDuration={maxDuration} onMaxDurationChange={setMaxDuration} version={version} onVersionChange={setVersion} versions={versions} region={region} onRegionChange={setRegion} regions={regions} isDev={isDev} session={session} runFriendlyId={activeConversation?.runFriendlyId ?? undefined} messageCount={messages.length} isStreaming={isStreaming} status={status} recentConversations={recentConversations} currentConversationId={conversationId} actionPath={actionPath} />
); } // Message rendering — `MessageBubble` is imported from // `~/components/runs/v3/agent/AgentMessageView`. The same module is used by // the run details Agent view so both surfaces stay in sync. function ChatComposer({ inputRef, value, onChange, onKeyDown, placeholder, minHeightClassName, maxHeightClassName, trailing, footer, }: { inputRef: React.RefObject; value: string; onChange: (val: string) => void; onKeyDown: (e: React.KeyboardEvent) => void; placeholder: string; minHeightClassName: string; maxHeightClassName: string; trailing?: React.ReactNode; footer?: React.ReactNode; }) { return (