+
+ {data.map((value, i) => {
+ const height = max > 0 ? Math.max((value / max) * 100, value > 0 ? 8 : 0) : 0;
+ return (
+
0 ? barColor : "transparent",
+ opacity: value > 0 ? 0.8 : 0,
+ }}
+ />
+ );
+ })}
+
+
{formatTotal(total)}
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx
new file mode 100644
index 00000000000..2a7664e0cbc
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx
@@ -0,0 +1,1227 @@
+import {
+ ArrowUpIcon,
+ BoltIcon,
+ CpuChipIcon,
+ StopIcon,
+ ArrowPathIcon,
+ TrashIcon,
+} from "@heroicons/react/20/solid";
+import { type MetaFunction } from "@remix-run/node";
+import { Link, useFetcher, useNavigate, useRouteLoaderData } from "@remix-run/react";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useChat } from "@ai-sdk/react";
+import { TriggerChatTransport } from "@trigger.dev/sdk/chat";
+import { MainCenteredContainer } from "~/components/layout/AppLayout";
+import { Badge } from "~/components/primitives/Badge";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
+import { CopyButton } from "~/components/primitives/CopyButton";
+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 { Spinner } from "~/components/primitives/Spinner";
+import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
+import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
+import type { PlaygroundConversation } from "~/presenters/v3/PlaygroundPresenter.server";
+import { DateTime } from "~/components/primitives/DateTime";
+import { cn } from "~/utils/cn";
+import { JSONEditor } from "~/components/code/JSONEditor";
+import { ToolUseRow, AssistantResponse, ChatBubble } from "~/components/runs/v3/ai/AIChatMessages";
+import { MessageBubble } from "~/components/runs/v3/agent/AgentMessageView";
+import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from "~/components/primitives/Resizable";
+import {
+ ClientTabs,
+ ClientTabsContent,
+ ClientTabsList,
+ ClientTabsTrigger,
+} from "~/components/primitives/ClientTabs";
+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 { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server";
+import { requireUserId } from "~/services/session.server";
+import { RunTagInput } from "~/components/runs/v3/RunTagInput";
+import { Select, SelectItem } from "~/components/primitives/Select";
+import { EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
+import { env as serverEnv } from "~/env.server";
+import { generateJWT as internal_generateJWT, MachinePresetName } from "@trigger.dev/core/v3";
+import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
+import { SchemaTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent";
+import { AIPayloadTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent";
+import type { UIMessage } from "@ai-sdk/react";
+
+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 agents = parentData?.agents ?? [];
+ 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]
+ );
+
+ // Resource route prefix — all realtime traffic goes through session-authed routes
+ const playgroundBaseURL = `${apiOrigin}/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,
+ clientData: JSON.parse(clientDataJson || "{}") as Record,
+ ...(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(() => {
+ transport.setClientData(JSON.parse(clientDataJson || "{}") as Record);
+ }, [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);
+
+ 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);
+ }, [input, pending]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ },
+ [handleSend]
+ );
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
{
+ if (slug && typeof slug === "string" && slug !== agent.slug) {
+ navigate(v3PlaygroundAgentPath(organization, project, environment, slug));
+ }
+ }}
+ icon={ }
+ text={(val) => val || undefined}
+ variant="tertiary/small"
+ items={agents}
+ filter={(item, search) =>
+ item.slug.toLowerCase().includes(search.toLowerCase())
+ }
+ >
+ {(matches) =>
+ matches.map((a) => (
+
+
+
+ {a.slug}
+
+
+ ))
+ }
+
+
{formatAgentType(agent.type)}
+
+
+ {activeConversation?.runFriendlyId && (
+
+ View run
+
+ )}
+ {messages.length > 0 && (
+
+ Copy raw
+
+ )}
+
+ {conversationId && (
+
+ )}
+
+ New conversation
+
+
+
+
+ {/* Messages */}
+
+ {messages.length === 0 ? (
+
+
+ {preloaded ? (
+ <>
+
+
Preloaded
+
+ Agent is warmed up and waiting. Type a message below to start.
+
+ >
+ ) : (
+ <>
+
+
Start a conversation
+
+ Type a message below to start testing{" "}
+ {agent.slug}
+
+ {!session && (
+
+ {preloading ? "Preloading..." : "Preload"}
+
+ )}
+ >
+ )}
+
+
+ ) : (
+
+ {messages.map((msg) => (
+
+ ))}
+ {isSubmitted && (
+
+ )}
+
+ )}
+
+
+ {/* Error */}
+ {error && (
+
+ {error.message}
+
+ )}
+
+ {/* Input */}
+
+
+ {/* Pending messages overlay */}
+ {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"
+ : "Press Enter to send, Shift+Enter for new line"}
+
+
+
+
+
+
+
+
+ );
+}
+
+function formatAgentType(type: string): string {
+ switch (type) {
+ case "ai-sdk-chat":
+ return "AI SDK Chat";
+ default:
+ return type;
+ }
+}
+
+// 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.
+
+// ---------------------------------------------------------------------------
+// Sidebar
+// ---------------------------------------------------------------------------
+
+const machinePresets = Object.values(MachinePresetName.enum);
+
+function PlaygroundSidebar({
+ clientDataJson,
+ onClientDataChange,
+ getCurrentClientData,
+ clientDataSchema,
+ agentSlug,
+ machine,
+ onMachineChange,
+ tags,
+ onTagsChange,
+ maxAttempts,
+ onMaxAttemptsChange,
+ maxDuration,
+ onMaxDurationChange,
+ version,
+ onVersionChange,
+ versions,
+ region,
+ onRegionChange,
+ regions,
+ isDev,
+ session,
+ runFriendlyId,
+ messageCount,
+ isStreaming,
+ status,
+}: {
+ clientDataJson: string;
+ onClientDataChange: (val: string) => void;
+ getCurrentClientData: () => string;
+ clientDataSchema: unknown;
+ agentSlug: string;
+ machine: string | undefined;
+ onMachineChange: (val: string | undefined) => void;
+ tags: string[];
+ onTagsChange: (val: string[]) => void;
+ maxAttempts: number | undefined;
+ onMaxAttemptsChange: (val: number | undefined) => void;
+ maxDuration: number | undefined;
+ onMaxDurationChange: (val: number | undefined) => void;
+ version: string | undefined;
+ onVersionChange: (val: string | undefined) => void;
+ versions: string[];
+ region: string | undefined;
+ onRegionChange: (val: string | undefined) => void;
+ regions: Array<{ id: string; name: string; description?: string; isDefault: boolean }>;
+ isDev: boolean;
+ session:
+ | {
+ publicAccessToken: string;
+ lastEventId?: string;
+ isStreaming?: boolean;
+ }
+ | undefined;
+ /**
+ * Friendly id of the latest run for this conversation (drawn from the
+ * playground's own `playgroundConversation` table, which mirrors the
+ * Session's `currentRunId`). Optional because a conversation may
+ * exist briefly before the first run lands.
+ */
+ runFriendlyId: string | undefined;
+ messageCount: number;
+ isStreaming: boolean;
+ status: string;
+}) {
+ const regionItems = regions.map((r) => ({
+ value: r.name,
+ label: r.description ? `${r.name} — ${r.description}` : r.name,
+ }));
+ return (
+
+
+
+
+
+ Client Data
+
+
+ Options
+
+
+ Session
+
+
+
+
+ {/* Client Data tab */}
+
+
+
+
+ Custom metadata sent with each conversation turn.
+
+
+
+
+
+
+
+
+ {clientDataSchema != null && (
+
+ )}
+
+
+
+ {/* Options tab */}
+
+
+
+
+ Machine
+
+
+ onMachineChange(val && typeof val === "string" ? val : undefined)
+ }
+ placeholder="Default"
+ variant="tertiary/small"
+ items={machinePresets}
+ filter={(item, search) => item.toLowerCase().includes(search.toLowerCase())}
+ >
+ {(matches) =>
+ matches.map((preset) => (
+
+ {preset}
+
+ ))
+ }
+
+ Overrides the machine preset.
+
+
+
+
+ Tags
+
+
+ Add tags to easily filter runs. 3 max (2 added automatically).
+
+
+
+
+ Max attempts
+
+ {
+ const val = e.target.value;
+ onMaxAttemptsChange(val ? parseInt(val, 10) : undefined);
+ }}
+ />
+ Retries failed runs up to the specified number of attempts.
+
+
+
+
+ Max duration
+
+
+ Overrides the maximum compute time limit for the run.
+
+
+ {versions.length > 0 && (
+
+
+ Version
+
+
+ onVersionChange(val && typeof val === "string" ? val : undefined)
+ }
+ placeholder="Latest"
+ variant="tertiary/small"
+ disabled={isDev}
+ items={versions}
+ filter={(item, search) => item.toLowerCase().includes(search.toLowerCase())}
+ >
+ {(matches) =>
+ matches.map((v, i) => (
+
+ {i === 0 ? `${v} (latest)` : v}
+
+ ))
+ }
+
+
+ {isDev
+ ? "Version is determined by the running dev server."
+ : "Lock the run to a specific deployed version."}
+
+
+ )}
+
+ {regionItems.length > 1 && (
+
+
+ Region
+
+
+ onRegionChange(val && typeof val === "string" ? val : undefined)
+ }
+ text={(val) => val || undefined}
+ placeholder={isDev ? "–" : "Default"}
+ variant="tertiary/small"
+ disabled={isDev}
+ items={regionItems}
+ filter={(item, search) =>
+ item.label.toLowerCase().includes(search.toLowerCase())
+ }
+ >
+ {(matches) =>
+ matches.map((r) => (
+
+ {r.label}
+
+ ))
+ }
+
+
+ {isDev
+ ? "Region is not applicable in development."
+ : "Run the agent in a specific region."}
+
+
+ )}
+
+
+
+ {/* Session tab */}
+
+
+ {session ? (
+ <>
+ {runFriendlyId && (
+
+ )}
+
+
+
+ Status
+
+
+
+ {status}
+
+
+ >
+ ) : (
+
+ No active session. Send a message to start a conversation.
+
+ )}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Pending messages hook (reimplemented to avoid React version mismatch)
+// ---------------------------------------------------------------------------
+
+const PENDING_MESSAGE_INJECTED_TYPE = "data-pending-message-injected";
+
+type PendingMessageEntry = {
+ id: string;
+ text: string;
+ mode: "steering" | "queued";
+ injected: boolean;
+};
+
+function usePlaygroundPendingMessages({
+ transport,
+ chatId,
+ status,
+ messages,
+ sendMessage,
+ metadata,
+}: {
+ transport: TriggerChatTransport;
+ chatId: string;
+ status: string;
+ messages: UIMessage[];
+ sendMessage: (msg: { text: string }, opts?: { metadata?: Record }) => void;
+ metadata?: Record;
+}) {
+ type InternalMsg = {
+ id: string;
+ role: "user";
+ parts: { type: "text"; text: string }[];
+ _mode: "steering" | "queued";
+ };
+ const [pendingMsgs, setPendingMsgs] = useState([]);
+ const injectedIdsRef = useRef>(new Set());
+ const prevStatusRef = useRef(status);
+
+ // Watch for injection confirmation chunks
+ useEffect(() => {
+ if (status !== "streaming") return;
+ let newlyInjected = false;
+ for (const msg of messages) {
+ if (msg.role !== "assistant") continue;
+ for (const part of msg.parts ?? []) {
+ if ((part as any).type === PENDING_MESSAGE_INJECTED_TYPE) {
+ const messageIds = (part as any).data?.messageIds as string[] | undefined;
+ if (Array.isArray(messageIds)) {
+ for (const id of messageIds) {
+ if (!injectedIdsRef.current.has(id)) {
+ injectedIdsRef.current.add(id);
+ newlyInjected = true;
+ }
+ }
+ }
+ }
+ }
+ }
+ if (newlyInjected) {
+ setPendingMsgs((prev) => prev.filter((m) => !injectedIdsRef.current.has(m.id)));
+ }
+ }, [status, messages]);
+
+ // Handle turn completion — auto-send non-injected messages as next turn
+ useEffect(() => {
+ const turnCompleted = prevStatusRef.current === "streaming" && status === "ready";
+ prevStatusRef.current = status;
+ if (!turnCompleted) return;
+
+ const toSend = pendingMsgs.filter((m) => !injectedIdsRef.current.has(m.id));
+ setPendingMsgs([]);
+ injectedIdsRef.current.clear();
+
+ if (toSend.length > 0) {
+ const text = toSend.map((m) => m.parts[0]?.text ?? "").join("\n");
+ sendMessage({ text }, metadata ? { metadata } : undefined);
+ }
+ }, [status, pendingMsgs, sendMessage, metadata, messages]);
+
+ const steer = useCallback(
+ (text: string) => {
+ if (status === "streaming") {
+ const msg: InternalMsg = {
+ id: crypto.randomUUID(),
+ role: "user",
+ parts: [{ type: "text", text }],
+ _mode: "steering",
+ };
+ transport.sendPendingMessage(chatId, msg as any, metadata);
+ setPendingMsgs((prev) => [...prev, msg]);
+ } else {
+ sendMessage({ text }, metadata ? { metadata } : undefined);
+ }
+ },
+ [status, transport, chatId, sendMessage, metadata]
+ );
+
+ const pending: PendingMessageEntry[] = pendingMsgs.map((m) => ({
+ id: m.id,
+ text: m.parts[0]?.text ?? "",
+ mode: m._mode,
+ injected: injectedIdsRef.current.has(m.id),
+ }));
+
+ return { pending, steer };
+}
+
+function RecentConversationsPopover({
+ conversations,
+ actionPath,
+}: {
+ conversations: PlaygroundConversation[];
+ actionPath: string;
+}) {
+ const fetcher = useFetcher();
+ const [isOpen, setIsOpen] = useState(false);
+
+ const deletingId =
+ fetcher.state !== "idle" ? (fetcher.formData?.get("deleteConversationId") as string) : null;
+
+ const handleDelete = useCallback(
+ (e: React.MouseEvent, conv: PlaygroundConversation) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ fetcher.submit(
+ {
+ intent: "delete",
+ agentSlug: conv.agentSlug,
+ deleteConversationId: conv.id,
+ },
+ { method: "POST", action: actionPath }
+ );
+ setIsOpen(false);
+ },
+ [actionPath, fetcher]
+ );
+
+ return (
+
+
+
+ Recent
+
+
+
+
+
+ {conversations.map((conv) => (
+
+
setIsOpen(false)}
+ className="flex min-w-0 flex-1 flex-col items-start gap-0.5 outline-none focus-custom"
+ >
+
+ {conv.title}
+
+
+
+
+
+
handleDelete(e, conv)}
+ className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity group-hover:opacity-100 hover:text-error"
+ >
+
+
+
+ ))}
+ {conversations.length === 0 && (
+
+ No recent conversations
+
+ )}
+
+
+
+
+ );
+}
+
+function safeParseJson(json: string): Record {
+ try {
+ return JSON.parse(json || "{}") as Record;
+ } catch {
+ return {};
+ }
+}
+
+function SessionField({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx
new file mode 100644
index 00000000000..08856f65aca
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx
@@ -0,0 +1,189 @@
+import { BookOpenIcon, CpuChipIcon } from "@heroicons/react/20/solid";
+import { json, type MetaFunction } from "@remix-run/node";
+import { Outlet, useNavigate, useParams, useLoaderData } from "@remix-run/react";
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { CodeBlock } from "~/components/code/CodeBlock";
+import { InlineCode } from "~/components/code/InlineCode";
+import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { LinkButton } from "~/components/primitives/Buttons";
+import { Header2 } from "~/components/primitives/Headers";
+import { InfoPanel } from "~/components/primitives/InfoPanel";
+import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import {
+ Select,
+ SelectItem,
+} from "~/components/primitives/Select";
+import { $replica } from "~/db.server";
+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 { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server";
+import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
+import { requireUser } from "~/services/session.server";
+import { docsPath, EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
+
+export const meta: MetaFunction = () => {
+ return [{ title: "Playground | Trigger.dev" }];
+};
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUser(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
+ if (!project) {
+ throw new Response(undefined, { status: 404, statusText: "Project not found" });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
+ if (!environment) {
+ throw new Response(undefined, { status: 404, statusText: "Environment not found" });
+ }
+
+ const [agents, backgroundWorkers, regionsResult] = await Promise.all([
+ playgroundPresenter.listAgents({
+ environmentId: environment.id,
+ environmentType: environment.type,
+ }),
+ $replica.backgroundWorker.findMany({
+ where: { runtimeEnvironmentId: environment.id },
+ select: { version: true },
+ orderBy: { createdAt: "desc" },
+ take: 20,
+ }),
+ new RegionsPresenter().call({
+ userId: user.id,
+ projectSlug: projectParam,
+ isAdmin: user.admin || user.isImpersonating,
+ }),
+ ]);
+
+ return json({
+ agents,
+ versions: backgroundWorkers.map((w) => w.version),
+ regions: regionsResult.regions,
+ isDev: environment.type === "DEVELOPMENT",
+ });
+};
+
+export default function PlaygroundPage() {
+ const { agents } = useLoaderData();
+ const organization = useOrganization();
+ const project = useProject();
+ const environment = useEnvironment();
+ const navigate = useNavigate();
+ const params = useParams();
+ const selectedAgent = params.agentParam ?? "";
+
+ if (agents.length === 0) {
+ return (
+
+
+
+
+
+
+
+ Agent docs
+
+ }
+ >
+
+ The Playground lets you test your AI agents with an interactive chat interface,
+ realtime streaming, and conversation history.
+
+
+ Define a chat agent using{" "}
+ chat.agent() :
+
+ {
+ return streamText({
+ model: openai("gpt-4o"),
+ messages,
+ abortSignal: signal,
+ });
+ },
+});`}
+ showLineNumbers={false}
+ showOpenInModal={false}
+ />
+
+ Deploy your project and your agents will appear here ready to test.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {selectedAgent ? (
+
+ ) : (
+
+
+
+
Select an agent
+
+ Choose an agent to start a conversation.
+
+
{
+ if (slug && typeof slug === "string") {
+ navigate(v3PlaygroundAgentPath(organization, project, environment, slug));
+ }
+ }}
+ icon={ }
+ text={(val) => val || undefined}
+ placeholder="Select an agent..."
+ variant="tertiary/small"
+ items={agents}
+ filter={(item, search) =>
+ item.slug.toLowerCase().includes(search.toLowerCase())
+ }
+ >
+ {(matches) =>
+ matches.map((agent) => (
+
+
+
+ {agent.slug}
+
+
+ ))
+ }
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
new file mode 100644
index 00000000000..496a5fb6295
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
@@ -0,0 +1,539 @@
+import { ArrowsRightLeftIcon, BookOpenIcon, XCircleIcon } from "@heroicons/react/24/solid";
+import { type MetaFunction } from "@remix-run/react";
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { z } from "zod";
+import { CodeBlock } from "~/components/code/CodeBlock";
+import { PageBody } from "~/components/layout/AppLayout";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
+import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
+import { CopyableText } from "~/components/primitives/CopyableText";
+import { DateTime } from "~/components/primitives/DateTime";
+import { Header2 } from "~/components/primitives/Headers";
+import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
+import SegmentedControl from "~/components/primitives/SegmentedControl";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import * as Property from "~/components/primitives/PropertyTable";
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from "~/components/primitives/Resizable";
+import { TabButton, TabContainer } from "~/components/primitives/Tabs";
+import { TextLink } from "~/components/primitives/TextLink";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
+import { AgentView } from "~/components/runs/v3/agent/AgentView";
+import { RealtimeStreamViewer } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
+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 SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
+import { requireUserId } from "~/services/session.server";
+import { cn } from "~/utils/cn";
+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) {
+ throw new Error("Environment not found");
+ }
+
+ const presenter = new SessionPresenter($replica);
+ const session = await presenter.call({
+ userId,
+ environmentId: environment.id,
+ sessionParam,
+ });
+
+ 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
+
+ {status === "ACTIVE" && (
+
+
+
+ Close session…
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+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 stream: "out" | "in" = value("stream") === "in" ? "in" : "out";
+
+ 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`;
+
+ return (
+
+
+
+
replace({ raw: v === "raw" ? "1" : undefined })}
+ />
+
+ {isRaw ? (
+
+
+ replace({ stream: undefined })}
+ >
+ Output
+
+ replace({ stream: "in" })}
+ >
+ Input
+
+
+ }
+ />
+
+ ) : (
+
+ )}
+
+ );
+}
+
+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();
+
+ return (
+
+
+
+ Status
+
+
+
+
+
+ Friendly ID
+
+
+
+
+ {session.externalId ? (
+
+ External ID
+
+
+
+
+ ) : null}
+
+ Type
+
+ {session.type}
+
+
+
+ Task
+
+ {session.taskIdentifier}
+
+
+ {session.currentRun ? (
+
+ Current run
+
+
+
+ {session.currentRun.friendlyId}
+ }
+ content={descriptionForTaskRunStatus(session.currentRun.status)}
+ disableHoverableContent
+ />
+
+
+
+
+ ) : 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,
+ allRunsPath,
+}: {
+ session: LoadedSession;
+ allRunsPath: string;
+}) {
+ const organization = useOrganization();
+ const project = useProject();
+ const environment = useEnvironment();
+
+ if (session.runs.length === 0) {
+ return No runs yet. ;
+ }
+
+ return (
+
+
+ {session.runs.map((entry) => {
+ const runPath = entry.run
+ ? v3RunPath(organization, project, environment, {
+ friendlyId: entry.run.friendlyId,
+ })
+ : undefined;
+ return (
+
+
+
+ {entry.reason}
+
+
+
+
+
+
+ {entry.run && runPath ? (
+
+
+
+
+ }
+ content={`Jump to run`}
+ disableHoverableContent
+ />
+ ) : (
+ –
+ )}
+
+
+ );
+ })}
+
+
+
+ View all runs
+
+
+
+ );
+}
+
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent.tsx
index b7a43a75028..a5e6a39076c 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent.tsx
@@ -9,18 +9,34 @@ import { docsPath } from "~/utils/pathBuilder";
export function SchemaTabContent({
schema,
inferredSchema,
+ title = "Payload schema",
+ description,
+ showDocsLink = true,
}: {
schema?: unknown;
inferredSchema?: unknown;
+ title?: string;
+ description?: string;
+ showDocsLink?: boolean;
}) {
if (schema) {
return (
-
Payload schema
-
- JSON Schema defined by this task via{" "}
- schemaTask .
-
+
{title}
+ {showDocsLink ? (
+
+ {description ?? (
+ <>
+ JSON Schema defined by this task via{" "}
+ schemaTask .
+ >
+ )}
+
+ ) : description ? (
+
+ {description}
+
+ ) : null}
{
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ return json({ error: "Project not found" }, { status: 404 });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ return json({ error: "Environment not found" }, { status: 404 });
+ }
+
+ const formData = await request.formData();
+ const parsed = PlaygroundAction.safeParse(Object.fromEntries(formData));
+ if (!parsed.success) {
+ return json({ error: "Invalid request", details: parsed.error.issues }, { status: 400 });
+ }
+
+ const { intent } = parsed.data;
+
+ switch (intent) {
+ case "create": {
+ const { agentSlug } = parsed.data;
+ const chatId = crypto.randomUUID();
+
+ const conversation = await prisma.playgroundConversation.create({
+ data: {
+ chatId,
+ agentSlug,
+ projectId: project.id,
+ runtimeEnvironmentId: environment.id,
+ userId,
+ },
+ });
+
+ return json({
+ conversationId: conversation.id,
+ chatId,
+ });
+ }
+
+ case "start": {
+ const {
+ agentSlug,
+ chatId,
+ payload: payloadStr,
+ clientData,
+ tags: tagsStr,
+ machine,
+ maxAttempts,
+ maxDuration,
+ version,
+ region,
+ } = parsed.data;
+
+ if (!chatId) {
+ return json({ error: "chatId is required" }, { status: 400 });
+ }
+
+ // Parse the optional initial payload — used as the basePayload
+ // for the first run trigger. After session create, the agent
+ // reads subsequent messages from `.in/append` so the payload
+ // here is just the bootstrap.
+ let payload: Record = {};
+ try {
+ payload = payloadStr ? (JSON.parse(payloadStr) as Record) : {};
+ } catch {
+ return json({ error: "Invalid payload JSON" }, { status: 400 });
+ }
+
+ let parsedClientData: unknown;
+ try {
+ parsedClientData = clientData ? JSON.parse(clientData) : undefined;
+ } catch {
+ /* invalid JSON — fall through with undefined */
+ }
+
+ const tags = [
+ `chat:${chatId}`,
+ "playground:true",
+ ...(tagsStr ? tagsStr.split(",").map((t) => t.trim()).filter(Boolean) : []),
+ ].slice(0, 5);
+
+ const triggerConfig = {
+ basePayload: {
+ // The first run boots before the user's first message lands on
+ // `.in/append`, so it sees `messages: []` and `trigger: "preload"`.
+ // Mirrors the defaults in `chat.createStartSessionAction` —
+ // chat.agent's runtime reads `payload.messages.length` so the
+ // field must be an array, not undefined.
+ messages: [],
+ trigger: "preload",
+ ...payload,
+ chatId,
+ ...(parsedClientData ? { metadata: parsedClientData } : {}),
+ },
+ ...(machine ? { machine } : {}),
+ tags,
+ ...(maxAttempts ? { maxAttempts: parseInt(maxAttempts, 10) } : {}),
+ ...(maxDuration ? { maxDuration: parseInt(maxDuration, 10) } : {}),
+ ...(version ? { lockToVersion: version } : {}),
+ ...(region ? { region } : {}),
+ };
+
+ // Atomic: upsert the Session, then trigger the first run via
+ // the optimistic-claim path. The transport's `accessToken`
+ // callback hits this endpoint on initial start AND on 401 — the
+ // upsert + ensureRunForSession combo is idempotent so repeat
+ // calls converge to the same session and (if alive) reuse the
+ // existing run.
+ const { id: sessionId, friendlyId } = SessionId.generate();
+ const session = await prisma.session.upsert({
+ where: {
+ runtimeEnvironmentId_externalId: {
+ runtimeEnvironmentId: environment.id,
+ externalId: chatId,
+ },
+ },
+ create: {
+ id: sessionId,
+ friendlyId,
+ externalId: chatId,
+ type: "chat.agent",
+ taskIdentifier: agentSlug,
+ triggerConfig: triggerConfig as unknown as Prisma.InputJsonValue,
+ tags: ["playground"],
+ projectId: project.id,
+ runtimeEnvironmentId: environment.id,
+ environmentType: environment.type,
+ organizationId: project.organizationId,
+ // Stamp the org's S2 basin so realtime reads on this
+ // session's `.in/.out` channels resolve without joining
+ // Organization. Null until per-org basins are provisioned.
+ streamBasinName: environment.organization.streamBasinName,
+ },
+ update: {
+ // Refresh trigger config in case agent version / params changed
+ triggerConfig: triggerConfig as unknown as Prisma.InputJsonValue,
+ },
+ });
+
+ const ensureResult = await ensureRunForSession({
+ session,
+ environment,
+ reason: "initial",
+ });
+
+ const run = await prisma.taskRun.findFirst({
+ where: { id: ensureResult.runId },
+ select: { friendlyId: true },
+ });
+ if (!run) {
+ return json({ error: "Triggered run not found" }, { status: 500 });
+ }
+
+ // Title: prefer the user message text on first start, else a
+ // generic placeholder. The conversation row is the playground's
+ // own surface — separate from the Session row that drives the
+ // trigger.
+ const firstMessage = payload?.messages?.[0];
+ const firstText =
+ firstMessage?.parts?.find((p: any) => p.type === "text")?.text ?? "New conversation";
+ const title = firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText;
+
+ const conversation = await prisma.playgroundConversation.upsert({
+ where: {
+ chatId_runtimeEnvironmentId: {
+ chatId,
+ runtimeEnvironmentId: environment.id,
+ },
+ },
+ create: {
+ chatId,
+ title,
+ agentSlug,
+ runId: ensureResult.runId,
+ clientData: parsedClientData as any,
+ projectId: project.id,
+ runtimeEnvironmentId: environment.id,
+ userId,
+ },
+ update: {
+ runId: ensureResult.runId,
+ clientData: parsedClientData as any,
+ title,
+ },
+ });
+
+ const publicAccessToken = await mintSessionToken(environment, chatId);
+
+ return json({
+ runId: run.friendlyId,
+ publicAccessToken,
+ conversationId: conversation.id,
+ });
+ }
+
+ case "save": {
+ const { chatId, messages: messagesStr, lastEventId } = parsed.data;
+ if (!chatId) {
+ return json({ error: "chatId is required" }, { status: 400 });
+ }
+
+ let messagesData: unknown;
+ try {
+ messagesData = messagesStr ? JSON.parse(messagesStr) : undefined;
+ } catch {
+ return json({ error: "Invalid messages JSON" }, { status: 400 });
+ }
+
+ // Extract title from the first user message if the conversation still has the default title.
+ // This handles the case where a preloaded conversation gets its first real message
+ // via the input stream (bypassing the trigger action that normally sets the title).
+ let titleUpdate: { title: string } | undefined;
+ if (messagesData && Array.isArray(messagesData)) {
+ const existing = await prisma.playgroundConversation.findFirst({
+ where: { chatId, runtimeEnvironmentId: environment.id },
+ select: { title: true },
+ });
+
+ if (existing?.title === "New conversation") {
+ const firstUserMsg = messagesData.find(
+ (m: any) => m.role === "user"
+ ) as Record | undefined;
+ const firstText =
+ firstUserMsg?.parts?.find((p: any) => p.type === "text")?.text ??
+ firstUserMsg?.content;
+ if (firstText && typeof firstText === "string") {
+ titleUpdate = {
+ title: firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText,
+ };
+ }
+ }
+ }
+
+ await prisma.playgroundConversation.updateMany({
+ where: {
+ chatId,
+ runtimeEnvironmentId: environment.id,
+ },
+ data: {
+ ...(messagesData ? { messages: messagesData as any } : {}),
+ ...(lastEventId ? { lastEventId } : {}),
+ ...titleUpdate,
+ },
+ });
+
+ return json({ ok: true });
+ }
+
+ case "delete": {
+ const { deleteConversationId } = parsed.data;
+ if (!deleteConversationId) {
+ return json({ error: "deleteConversationId is required" }, { status: 400 });
+ }
+
+ await prisma.playgroundConversation.deleteMany({
+ where: {
+ id: deleteConversationId,
+ runtimeEnvironmentId: environment.id,
+ userId,
+ },
+ });
+
+ return json({ ok: true });
+ }
+ }
+};
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.append.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.append.ts
new file mode 100644
index 00000000000..5ac6194edea
--- /dev/null
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.append.ts
@@ -0,0 +1,162 @@
+import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
+import { tryCatch } from "@trigger.dev/core/utils";
+import { nanoid } from "nanoid";
+import { z } from "zod";
+import { $replica } from "~/db.server";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import { logger } from "~/services/logger.server";
+import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
+import { ensureRunForSession } from "~/services/realtime/sessionRunManager.server";
+import {
+ canonicalSessionAddressingKey,
+ resolveSessionByIdOrExternalId,
+} from "~/services/realtime/sessions.server";
+import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
+import { drainSessionStreamWaitpoints } from "~/services/sessionStreamWaitpointCache.server";
+import { requireUserId } from "~/services/session.server";
+import { EnvironmentParamSchema } from "~/utils/pathBuilder";
+import { engine } from "~/v3/runEngine.server";
+import { ServiceValidationError } from "~/v3/services/common.server";
+
+const ParamsSchema = z.object({
+ session: z.string(),
+ io: z.enum(["out", "in"]),
+});
+
+// S2 record body cap. Mirrors the public /realtime/v1/sessions/:s/:io/append
+// route — keep it well under S2's 1 MiB per-record limit so JSON wrapping,
+// string escaping, and any future per-record headers stay safe.
+const MAX_APPEND_BODY_BYTES = 1024 * 512;
+
+// POST: Append a single record to a Session channel from the dashboard
+// playground. Mirrors the public `POST /realtime/v1/sessions/:session/:io/append`
+// but authenticates via the dashboard session cookie instead of a
+// session-scoped JWT.
+export async function action({ request, params }: ActionFunctionArgs) {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+ const { session: sessionParam, io } = ParamsSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ return json({ ok: false, error: "Project not found" }, { status: 404 });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ return json({ ok: false, error: "Environment not found" }, { status: 404 });
+ }
+
+ const contentLength = request.headers.get("content-length");
+ if (!contentLength || parseInt(contentLength) > MAX_APPEND_BODY_BYTES) {
+ return json({ ok: false, error: "Request body too large" }, { status: 413 });
+ }
+
+ const session = await resolveSessionByIdOrExternalId(
+ $replica,
+ environment.id,
+ sessionParam
+ );
+ if (!session) {
+ return json({ ok: false, error: "Session not found" }, { status: 404 });
+ }
+
+ if (session.closedAt) {
+ return json(
+ { ok: false, error: "Cannot append to a closed session" },
+ { status: 400 }
+ );
+ }
+
+ if (session.expiresAt && session.expiresAt.getTime() < Date.now()) {
+ return json(
+ { ok: false, error: "Cannot append to an expired session" },
+ { status: 400 }
+ );
+ }
+
+ const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session });
+
+ if (!(realtimeStream instanceof S2RealtimeStreams)) {
+ return json(
+ { ok: false, error: "Session channels require the S2 realtime backend" },
+ { status: 501 }
+ );
+ }
+
+ // Probe + ensure a live run before appending (mirrors public route).
+ // Best-effort: failure here doesn't block the append — the record is
+ // durable; the next append retries the ensure.
+ const [ensureError] = await tryCatch(
+ ensureRunForSession({
+ session,
+ environment,
+ reason: "continuation",
+ })
+ );
+ if (ensureError) {
+ logger.error("Failed to ensureRunForSession on playground .in/append", {
+ sessionId: session.id,
+ externalId: session.externalId,
+ error: ensureError,
+ });
+ }
+
+ const addressingKey = canonicalSessionAddressingKey(session, sessionParam);
+
+ const part = await request.text();
+ const partId = request.headers.get("X-Part-Id") ?? nanoid(7);
+
+ const [appendError] = await tryCatch(
+ realtimeStream.appendPartToSessionStream(part, partId, addressingKey, io)
+ );
+
+ if (appendError) {
+ if (appendError instanceof ServiceValidationError) {
+ return json(
+ { ok: false, error: appendError.message },
+ { status: appendError.status ?? 422 }
+ );
+ }
+ return json({ ok: false, error: appendError.message }, { status: 500 });
+ }
+
+ // Drain any waitpoints registered for this channel — same as the
+ // public append. Best-effort; failure doesn't fail the append.
+ const [drainError, waitpointIds] = await tryCatch(
+ drainSessionStreamWaitpoints(addressingKey, io)
+ );
+ if (drainError) {
+ logger.error("Failed to drain session stream waitpoints (playground)", {
+ addressingKey,
+ io,
+ error: drainError,
+ });
+ } else if (waitpointIds && waitpointIds.length > 0) {
+ await Promise.all(
+ waitpointIds.map(async (waitpointId) => {
+ const [completeError] = await tryCatch(
+ engine.completeWaitpoint({
+ id: waitpointId,
+ output: {
+ value: part,
+ type: "application/json",
+ isError: false,
+ },
+ })
+ );
+ if (completeError) {
+ logger.error("Failed to complete session stream waitpoint (playground)", {
+ addressingKey,
+ io,
+ waitpointId,
+ error: completeError,
+ });
+ }
+ })
+ );
+ }
+
+ return json({ ok: true }, { status: 200 });
+}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.ts
new file mode 100644
index 00000000000..bd898be2bce
--- /dev/null
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.ts
@@ -0,0 +1,92 @@
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { $replica } from "~/db.server";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
+import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
+import {
+ canonicalSessionAddressingKey,
+ resolveSessionByIdOrExternalId,
+} from "~/services/realtime/sessions.server";
+import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
+import { requireUserId } from "~/services/session.server";
+import { EnvironmentParamSchema } from "~/utils/pathBuilder";
+
+const ParamsSchema = z.object({
+ session: z.string(),
+ io: z.enum(["out", "in"]),
+});
+
+// HEAD/GET: SSE subscribe to a Session channel from the dashboard
+// playground. Mirrors the public `GET /realtime/v1/sessions/:session/:io`
+// route but authenticates via the dashboard session cookie instead of a
+// session-scoped JWT — the playground transport never holds a PAT.
+//
+// `:session` accepts either the `session_*` friendlyId or the externalId
+// the playground assigned (`chatId`). Resolution is environment-scoped
+// so users can't subscribe to sessions from other envs.
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+ const { session: sessionParam, io } = ParamsSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ return new Response("Project not found", { status: 404 });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ return new Response("Environment not found", { status: 404 });
+ }
+
+ const session = await resolveSessionByIdOrExternalId(
+ $replica,
+ environment.id,
+ sessionParam
+ );
+
+ if (!session) {
+ return new Response("Session not found", { status: 404 });
+ }
+
+ const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session });
+
+ if (!(realtimeStream instanceof S2RealtimeStreams)) {
+ return new Response("Session channels require the S2 realtime backend", {
+ status: 501,
+ });
+ }
+
+ if (request.method === "HEAD") {
+ // No last-chunk-index on the S2 backend (clients resume via
+ // Last-Event-ID on the SSE stream directly). Return 200 with a
+ // zero index for compatibility with the run-stream shape.
+ return new Response(null, {
+ status: 200,
+ headers: { "X-Last-Chunk-Index": "0" },
+ });
+ }
+
+ const lastEventId = request.headers.get("Last-Event-ID") || undefined;
+ const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
+ const timeoutInSeconds = timeoutInSecondsRaw ? parseInt(timeoutInSecondsRaw) : undefined;
+
+ if (
+ timeoutInSeconds &&
+ (isNaN(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600)
+ ) {
+ return new Response("Invalid timeout", { status: 400 });
+ }
+
+ const addressingKey = canonicalSessionAddressingKey(session, sessionParam);
+
+ return realtimeStream.streamResponseFromSessionStream(
+ request,
+ addressingKey,
+ io,
+ getRequestAbortSignal(),
+ { lastEventId, timeoutInSeconds }
+ );
+}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.$io.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.$io.ts
new file mode 100644
index 00000000000..e4dfda518b5
--- /dev/null
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.$io.ts
@@ -0,0 +1,106 @@
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { $replica } from "~/db.server";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
+import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
+import {
+ canonicalSessionAddressingKey,
+ resolveSessionByIdOrExternalId,
+} from "~/services/realtime/sessions.server";
+import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
+import { requireUserId } from "~/services/session.server";
+import { EnvironmentParamSchema } from "~/utils/pathBuilder";
+
+const ParamsSchema = z.object({
+ runParam: z.string(),
+ sessionId: z.string(),
+ io: z.enum(["out", "in"]),
+});
+
+// GET: SSE stream subscription for a backing Session's `.out` / `.in`
+// channel. Dashboard-auth counterpart to the public API's
+// `/realtime/v1/sessions/:sessionId/:io` endpoint. Used by the Agent tab
+// in the span inspector to observe assistant chunks (`.out`) and
+// user-side ChatInputChunk payloads (`.in`) for a chat.agent run.
+//
+// The `:sessionId` segment accepts either the `session_*` friendlyId or
+// the externalId the transport registered for the chat (typically the
+// browser's `chatId`). Runs pre-dating the Sessions migration that have
+// `chatId` but no `sessionId` in the payload take the externalId path.
+//
+// Authenticated by the dashboard session — the user must have access to
+// the project, environment, and run. The run binds this resource
+// hierarchy; the session identity is verified against the environment.
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+ const { runParam, sessionId, io } = ParamsSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ return new Response("Project not found", { status: 404 });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ return new Response("Environment not found", { status: 404 });
+ }
+
+ // Verify the run lives in this environment — keeps callers from
+ // subscribing to arbitrary sessions via `/runs/$runParam/...`.
+ const run = await $replica.taskRun.findFirst({
+ where: {
+ friendlyId: runParam,
+ runtimeEnvironmentId: environment.id,
+ },
+ select: { id: true, friendlyId: true },
+ });
+
+ if (!run) {
+ return new Response("Run not found", { status: 404 });
+ }
+
+ const session = await resolveSessionByIdOrExternalId(
+ $replica,
+ environment.id,
+ sessionId
+ );
+
+ if (!session) {
+ return new Response("Session not found", { status: 404 });
+ }
+
+ const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session });
+
+ if (!(realtimeStream instanceof S2RealtimeStreams)) {
+ return new Response("Session channels require the S2 realtime backend", {
+ status: 501,
+ });
+ }
+
+ const lastEventId = request.headers.get("Last-Event-ID") || undefined;
+ const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
+ const timeoutInSeconds = timeoutInSecondsRaw ? parseInt(timeoutInSecondsRaw) : undefined;
+
+ if (
+ timeoutInSeconds &&
+ (isNaN(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600)
+ ) {
+ return new Response("Invalid timeout", { status: 400 });
+ }
+
+ // The agent writes via the canonical addressing key (externalId if
+ // set, else friendlyId). Subscribe with the same key so the read
+ // hits the same S2 stream the agent is writing into.
+ const addressingKey = canonicalSessionAddressingKey(session, sessionId);
+
+ return realtimeStream.streamResponseFromSessionStream(
+ request,
+ addressingKey,
+ io,
+ getRequestAbortSignal(),
+ { lastEventId, timeoutInSeconds }
+ );
+}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.streams.$runId.$streamId.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.streams.$runId.$streamId.ts
new file mode 100644
index 00000000000..4e139cc7ce2
--- /dev/null
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.streams.$runId.$streamId.ts
@@ -0,0 +1,92 @@
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { $replica } from "~/db.server";
+import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
+import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import { requireUserId } from "~/services/session.server";
+import { EnvironmentParamSchema } from "~/utils/pathBuilder";
+
+const ParamsSchema = z.object({
+ runParam: z.string(),
+ runId: z.string(),
+ streamId: z.string(),
+});
+
+// GET: SSE stream subscription for a run's realtime output stream.
+//
+// The run-scoped equivalent of the playground stream route. Used by the
+// Agent tab in the span inspector to subscribe to the run's chat output
+// stream (streamed via `pipeChat` on the task side) through the dashboard
+// instead of hitting the public API directly.
+//
+// Authenticated by the dashboard session — the user must have access to
+// the project and environment.
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+ const { runParam, runId, streamId } = ParamsSchema.parse(params);
+
+ // Defensive: callers should pass the same friendly ID for both the route
+ // `:runParam` segment and the stream `:runId` segment.
+ if (runParam !== runId) {
+ return new Response("Run ID mismatch", { status: 400 });
+ }
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ return new Response("Project not found", { status: 404 });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ return new Response("Environment not found", { status: 404 });
+ }
+
+ const run = await $replica.taskRun.findFirst({
+ where: {
+ friendlyId: runId,
+ runtimeEnvironmentId: environment.id,
+ },
+ select: {
+ id: true,
+ friendlyId: true,
+ realtimeStreamsVersion: true,
+ streamBasinName: true,
+ },
+ });
+
+ if (!run) {
+ return new Response("Run not found", { status: 404 });
+ }
+
+ const lastEventId = request.headers.get("Last-Event-ID") || undefined;
+ const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
+ const timeoutInSeconds = timeoutInSecondsRaw ? parseInt(timeoutInSecondsRaw) : undefined;
+
+ if (
+ timeoutInSeconds &&
+ (isNaN(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600)
+ ) {
+ return new Response("Invalid timeout", { status: 400 });
+ }
+
+ const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion, {
+ run,
+ });
+
+ // `request.signal` is severed by Remix's Request.clone() + Node undici GC bug
+ // (see apps/webapp/CLAUDE.md). Use the Express res.on('close')-backed signal so
+ // the upstream stream fetch actually aborts when the user closes the tab.
+ return realtimeStream.streamResponse(
+ request,
+ run.friendlyId,
+ streamId,
+ getRequestAbortSignal(),
+ {
+ lastEventId,
+ timeoutInSeconds,
+ }
+ );
+}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.streams.$runId.input.$streamId.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.streams.$runId.input.$streamId.ts
new file mode 100644
index 00000000000..8b7492f29c8
--- /dev/null
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.streams.$runId.input.$streamId.ts
@@ -0,0 +1,93 @@
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { $replica } from "~/db.server";
+import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
+import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import { requireUserId } from "~/services/session.server";
+import { EnvironmentParamSchema } from "~/utils/pathBuilder";
+
+const ParamsSchema = z.object({
+ runParam: z.string(),
+ runId: z.string(),
+ streamId: z.string(),
+});
+
+// GET: SSE stream subscription for a run's realtime INPUT stream.
+//
+// Dashboard-auth counterpart to the public API's
+// `/realtime/v1/streams/:runId/input/:streamId` endpoint. Used by the Agent
+// tab in the span inspector to observe user messages sent to an agent run
+// over the `chat-messages` input stream.
+//
+// The underlying S2 stream name is `$trigger.input:${streamId}` (mirrors the
+// naming used on the write side in `sendInputStream`). The realtime stream
+// instance handles the actual SSE proxy; this route just enforces session
+// auth and resolves the run.
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+ const { runParam, runId, streamId } = ParamsSchema.parse(params);
+
+ // Defensive: callers should pass the same friendly ID for both the route
+ // `:runParam` segment and the stream `:runId` segment.
+ if (runParam !== runId) {
+ return new Response("Run ID mismatch", { status: 400 });
+ }
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ return new Response("Project not found", { status: 404 });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ return new Response("Environment not found", { status: 404 });
+ }
+
+ const run = await $replica.taskRun.findFirst({
+ where: {
+ friendlyId: runId,
+ runtimeEnvironmentId: environment.id,
+ },
+ select: {
+ id: true,
+ friendlyId: true,
+ realtimeStreamsVersion: true,
+ streamBasinName: true,
+ },
+ });
+
+ if (!run) {
+ return new Response("Run not found", { status: 404 });
+ }
+
+ const lastEventId = request.headers.get("Last-Event-ID") || undefined;
+ const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
+ const timeoutInSeconds = timeoutInSecondsRaw ? parseInt(timeoutInSecondsRaw) : undefined;
+
+ if (
+ timeoutInSeconds &&
+ (isNaN(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600)
+ ) {
+ return new Response("Invalid timeout", { status: 400 });
+ }
+
+ const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion, {
+ run,
+ });
+
+ // `request.signal` is severed by Remix's Request.clone() + Node undici GC bug
+ // (see apps/webapp/CLAUDE.md). Use the Express res.on('close')-backed signal.
+ return realtimeStream.streamResponse(
+ request,
+ run.friendlyId,
+ `$trigger.input:${streamId}`,
+ getRequestAbortSignal(),
+ {
+ lastEventId,
+ timeoutInSeconds,
+ }
+ );
+}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx
index e0bec66ffb2..3e4c231cc1f 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx
@@ -53,6 +53,7 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
+import { SessionStatusCombo } from "~/components/sessions/v1/SessionStatus";
import { TextLink } from "~/components/primitives/TextLink";
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/RunTimeline";
@@ -88,6 +89,7 @@ import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import {
docsPath,
v3BatchPath,
+ v3SessionPath,
v3DeploymentVersionPath,
v3LogsPath,
v3RunDownloadLogsPath,
@@ -124,7 +126,26 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
linkedRunId,
});
- return typedjson(result);
+ if (!result) {
+ return redirectWithErrorMessage(
+ v3RunPath(
+ { slug: organizationSlug },
+ { slug: projectParam },
+ { slug: envParam },
+ { friendlyId: runParam }
+ ),
+ request,
+ `Event not found.`
+ );
+ }
+
+ // Reconstruct the discriminated union explicitly. Spreading
+ // `{ ...result }` collapses the union and loses the
+ // `type === "run" | "span"` discriminant downstream in `SpanView`.
+ if (result.type === "run") {
+ return typedjson({ type: "run" as const, run: result.run });
+ }
+ return typedjson({ type: "span" as const, span: result.span });
} catch (error) {
logger.error("Error loading span", {
projectParam,
@@ -618,6 +639,32 @@ function RunBody({
)}
+ {run.session && (
+
+ Session
+
+
+
+
+
+ }
+ content={`Jump to session (${run.session.reason})`}
+ disableHoverableContent
+ />
+
+
+ )}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx
index b6a72d3aa09..4a9581831c9 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx
@@ -101,17 +101,32 @@ export function RealtimeStreamViewer({
streamKey,
metadata,
displayName,
+ resourcePath: resourcePathOverride,
+ headerLabel,
+ headerLeft,
}: {
- runId: string;
- streamKey: string;
- metadata: Record
| undefined;
+ runId?: string;
+ streamKey?: string;
+ metadata?: Record | undefined;
displayName?: string;
+ /** Pre-built resource path. When provided, `runId`/`streamKey` are unused. */
+ resourcePath?: string;
+ /** Override the "Stream:" / "Input stream:" prefix in the header. */
+ headerLabel?: string;
+ /**
+ * Replaces the default "Stream: " content next to the connection
+ * icon. Use to inline tabs or other navigation in place of a static
+ * label.
+ */
+ headerLeft?: React.ReactNode;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
- const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/streams/${streamKey}`;
+ const resourcePath =
+ resourcePathOverride ??
+ `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/streams/${streamKey}`;
const startIndex = typeof metadata?.startIndex === "number" ? metadata.startIndex : undefined;
const { chunks, error, isConnected } = useRealtimeStream(resourcePath, startIndex);
@@ -229,7 +244,7 @@ export function RealtimeStreamViewer({
{/* Header */}
-
+
@@ -244,13 +259,17 @@ export function RealtimeStreamViewer({
-
- {displayName ? "Input stream:" : "Stream:"}
- {displayName ?? streamKey}
-
+ {headerLeft ?? (
+
+ {headerLabel ?? (displayName ? "Input stream:" : "Stream:")}
+
+ {displayName ?? streamKey ?? ""}
+
+
+ )}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts
new file mode 100644
index 00000000000..c8676cacb4d
--- /dev/null
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts
@@ -0,0 +1,84 @@
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { $replica } from "~/db.server";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
+import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
+import {
+ canonicalSessionAddressingKey,
+ resolveSessionByIdOrExternalId,
+} from "~/services/realtime/sessions.server";
+import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
+import { requireUserId } from "~/services/session.server";
+import { EnvironmentParamSchema } from "~/utils/pathBuilder";
+
+const ParamsSchema = z.object({
+ sessionParam: z.string(),
+ io: z.enum(["out", "in"]),
+});
+
+// GET: SSE stream subscription for a Session's `.out` / `.in` channel.
+// Dashboard-auth counterpart to the public API's
+// `/realtime/v1/sessions/:sessionId/:io`. Used by the Sessions detail
+// view (and the run page's Agent tab) to observe assistant chunks
+// (`.out`) and user-side ChatInputChunk payloads (`.in`).
+//
+// The `:sessionParam` segment accepts either the `session_*` friendlyId
+// or the externalId the transport registered for the chat (typically the
+// browser's `chatId`).
+//
+// Authenticated by the dashboard session — the user must have access to
+// the project and environment. The session must live in that environment.
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+ const { sessionParam, io } = ParamsSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ return new Response("Project not found", { status: 404 });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ return new Response("Environment not found", { status: 404 });
+ }
+
+ const session = await resolveSessionByIdOrExternalId($replica, environment.id, sessionParam);
+ if (!session) {
+ return new Response("Session not found", { status: 404 });
+ }
+
+ const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session });
+
+ if (!(realtimeStream instanceof S2RealtimeStreams)) {
+ return new Response("Session channels require the S2 realtime backend", {
+ status: 501,
+ });
+ }
+
+ const lastEventId = request.headers.get("Last-Event-ID") || undefined;
+ const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
+ const timeoutInSeconds = timeoutInSecondsRaw ? parseInt(timeoutInSecondsRaw) : undefined;
+
+ if (
+ timeoutInSeconds &&
+ (isNaN(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600)
+ ) {
+ return new Response("Invalid timeout", { status: 400 });
+ }
+
+ // The agent writes via the canonical addressing key (externalId if
+ // set, else friendlyId). Subscribe with the same key so the read
+ // hits the same S2 stream the agent is writing into.
+ const addressingKey = canonicalSessionAddressingKey(session, sessionParam);
+
+ return realtimeStream.streamResponseFromSessionStream(
+ request,
+ addressingKey,
+ io,
+ getRequestAbortSignal(),
+ { lastEventId, timeoutInSeconds }
+ );
+}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx
index a4a5b8900b6..6deadd16d83 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx
@@ -21,6 +21,7 @@ const RequestSchema = z.object({
taskIdentifier: z.string().max(256),
payloadSchema: z.string().max(50_000).optional(),
currentPayload: z.string().max(50_000).optional(),
+ isAgent: z.enum(["true", "false"]).optional(),
});
export async function action({ request, params }: ActionFunctionArgs) {
@@ -64,16 +65,20 @@ export async function action({ request, params }: ActionFunctionArgs) {
);
}
- const { prompt, taskIdentifier, payloadSchema, currentPayload } = submission.data;
+ const { prompt, taskIdentifier, payloadSchema, currentPayload, isAgent } = submission.data;
+ const agentMode = isAgent === "true";
logger.info("[AI payload] Generating payload", {
taskIdentifier,
hasPayloadSchema: !!payloadSchema,
hasCurrentPayload: !!currentPayload,
promptLength: prompt.length,
+ agentMode,
});
- const systemPrompt = buildSystemPrompt(taskIdentifier, payloadSchema, currentPayload);
+ const systemPrompt = agentMode
+ ? buildAgentClientDataPrompt(taskIdentifier, payloadSchema, currentPayload)
+ : buildSystemPrompt(taskIdentifier, payloadSchema, currentPayload);
const stream = new ReadableStream({
async start(controller) {
@@ -234,6 +239,60 @@ async function getTaskFromDeployment(environmentId: string, taskIdentifier: stri
return { fileId: task.fileId };
}
+function buildAgentClientDataPrompt(
+ taskIdentifier: string,
+ payloadSchema?: string,
+ currentPayload?: string
+): string {
+ let prompt = `You are a JSON generator for client data (metadata) of a Trigger.dev chat agent with id "${taskIdentifier}".
+
+IMPORTANT: You are generating ONLY the client data object — this is the metadata sent alongside each chat message. It is NOT the full task payload. Do NOT generate fields like "chatId", "messages", "trigger", or "idleTimeoutInSeconds" — those are internal transport fields managed by the framework.
+
+The client data typically contains user context like user IDs, preferences, configuration, or session info. Return ONLY valid JSON wrapped in a \`\`\`json code block.
+
+Requirements:
+- Generate realistic, meaningful example data
+- All string values should be plausible (real-looking IDs, names, etc.)
+- The JSON must be valid and parseable
+- Keep it simple — client data is usually a flat or shallow object`;
+
+ if (payloadSchema) {
+ prompt += `
+
+The agent has the following JSON Schema for its client data:
+\`\`\`json
+${payloadSchema}
+\`\`\`
+
+Generate client data that strictly conforms to this schema.`;
+ } else {
+ prompt += `
+
+No JSON Schema is available for this agent's client data. Use the getTaskSourceCode tool to look up the agent's source code file.
+
+IMPORTANT instructions for reading the source code:
+- The file may contain multiple task/agent definitions. Find the one with id "${taskIdentifier}".
+- Look for \`withClientData({ schema: ... })\` or \`clientDataSchema\` to find the expected client data shape.
+- If using \`chat.agent()\` or \`chat.customAgent()\`, the client data is accessed via \`clientData\` in hooks and \`payload.metadata\` in raw tasks.
+- Look for how \`clientData\` or \`payload.metadata\` is accessed/destructured to infer the shape.
+- Do NOT generate the full ChatTaskWirePayload (messages, chatId, trigger, etc.) — ONLY the metadata/clientData portion.
+- If no client data schema or usage is found, generate a simple \`{ "userId": "user_..." }\` object.`;
+ }
+
+ if (currentPayload) {
+ prompt += `
+
+The current client data in the editor is:
+\`\`\`json
+${currentPayload}
+\`\`\`
+
+Use this as context but generate new client data based on the user's prompt.`;
+ }
+
+ return prompt;
+}
+
function buildSystemPrompt(
taskIdentifier: string,
payloadSchema?: string,
diff --git a/apps/webapp/app/routes/storybook.streamdown/route.tsx b/apps/webapp/app/routes/storybook.streamdown/route.tsx
new file mode 100644
index 00000000000..8f2c0d3e89c
--- /dev/null
+++ b/apps/webapp/app/routes/storybook.streamdown/route.tsx
@@ -0,0 +1,117 @@
+import { Suspense } from "react";
+import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
+import { Header2 } from "~/components/primitives/Headers";
+
+const sampleMarkdown = `# Streamdown Rendering
+
+This is a paragraph with **bold**, *italic*, and \`inline code\` formatting.
+
+## Code Block (TypeScript)
+
+\`\`\`typescript
+import { task } from "@trigger.dev/sdk";
+
+export const myTask = task({
+ id: "my-task",
+ run: async (payload: { message: string }) => {
+ const result = await processMessage(payload.message);
+ this.logger.info("Task completed", { result });
+ return { success: true, count: 42 };
+ },
+});
+\`\`\`
+
+## Code Block (JSON)
+
+\`\`\`json
+{
+ "id": "run_1234",
+ "status": "completed",
+ "output": {
+ "success": true,
+ "count": 42
+ }
+}
+\`\`\`
+
+## Lists
+
+- First item
+- Second item with \`code\`
+- Third item
+
+1. Ordered first
+2. Ordered second
+3. Ordered third
+
+## Table
+
+| Feature | Status | Notes |
+|---------|--------|-------|
+| Syntax highlighting | Done | Custom Shiki theme |
+| Markdown rendering | Done | Streamdown v2 |
+| Lazy loading | Done | SSR safe |
+
+## Blockquote
+
+> This is a blockquote with some **bold** text and a [link](https://trigger.dev).
+
+---
+
+That's all the elements.
+`;
+
+const codeOnlyMarkdown = `Here's a function that demonstrates the color palette:
+
+\`\`\`typescript
+const API_URL = "https://api.trigger.dev";
+const MAX_RETRIES = 3;
+
+interface TaskConfig {
+ id: string;
+ retry: { maxAttempts: number };
+}
+
+export async function executeTask(config: TaskConfig): Promise {
+ // Validate the configuration
+ if (!config.id || config.retry.maxAttempts < 1) {
+ throw new Error("Invalid task config");
+ }
+
+ for (let i = 0; i < MAX_RETRIES; i++) {
+ const response = await fetch(\`\${API_URL}/tasks/\${config.id}\`);
+ const data = response.json();
+
+ if (response.ok) {
+ return true;
+ }
+ }
+
+ return false;
+}
+\`\`\`
+`;
+
+export default function Story() {
+ return (
+
+
+
Full Markdown
+
+ Loading streamdown...}>
+ {sampleMarkdown}
+
+
+
+
+
+
Code Highlighting Theme
+
+ Loading streamdown...}>
+ {codeOnlyMarkdown}
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx
index 3efa990548c..423bee7514c 100644
--- a/apps/webapp/app/routes/storybook/route.tsx
+++ b/apps/webapp/app/routes/storybook/route.tsx
@@ -104,6 +104,10 @@ const stories: Story[] = [
name: "Spinners",
slug: "spinner",
},
+ {
+ name: "Streamdown",
+ slug: "streamdown",
+ },
{
name: "Switch",
slug: "switch",
diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css
index 8ec29b91568..04ac2508784 100644
--- a/apps/webapp/app/tailwind.css
+++ b/apps/webapp/app/tailwind.css
@@ -151,11 +151,18 @@
/* Streamdown markdown styling */
.streamdown-container {
- /* Streamdown uses shadcn/ui CSS variables - define them for our theme */
- --muted: 220 13% 20%;
- --muted-foreground: 215 14% 60%;
- --foreground: 210 20% 90%;
- --border: 217 19% 27%;
+ /* Streamdown uses shadcn/ui CSS variables - define them for our theme.
+ These map Tailwind utility classes like bg-background, bg-primary, etc.
+ that streamdown uses internally for its link safety modal, code blocks,
+ and other interactive elements. */
+ --background: 230 16% 9%; /* charcoal-900 #121317 */
+ --foreground: 215 19% 87%; /* charcoal-200 #D7D9DD */
+ --muted: 220 8% 17%; /* charcoal-775 #1C1E21 */
+ --muted-foreground: 220 8% 57%; /* charcoal-400 #878C99 */
+ --border: 216 7% 27%; /* charcoal-650 #2C3034 */
+ --primary: 95 100% 66%; /* apple-500 #A8FF53 */
+ --primary-foreground: 230 16% 9%; /* charcoal-900 */
+ --sidebar: 228 10% 11%; /* charcoal-850 #15171A */;
/* Code block styling */
& [data-code-block-container] {
diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js
index d598ae83d20..f90a9fd71b1 100644
--- a/apps/webapp/tailwind.config.js
+++ b/apps/webapp/tailwind.config.js
@@ -184,7 +184,7 @@ const radius = "0.5rem";
/** @type {import('tailwindcss').Config} */
module.exports = {
- content: ["./app/**/*.{ts,jsx,tsx}"],
+ content: ["./app/**/*.{ts,jsx,tsx}", "./node_modules/streamdown/dist/**/*.js"],
theme: {
container: {
center: true,
@@ -264,6 +264,18 @@ module.exports = {
aiPrompts,
aiMetrics,
errors,
+ // shadcn/ui color tokens used by streamdown's internal components
+ // (link safety modal, code block actions, etc.)
+ // Values are defined via CSS variables in .streamdown-container
+ background: "hsl(var(--background, 230 16% 9%) / )",
+ foreground: "hsl(var(--foreground, 215 19% 87%) / )",
+ muted: {
+ DEFAULT: "hsl(var(--muted, 220 8% 17%) / )",
+ foreground: "hsl(var(--muted-foreground, 220 8% 57%) / )",
+ },
+ border: "hsl(var(--border, 216 7% 27%) / )",
+ sidebar: "hsl(var(--sidebar, 228 10% 11%) / )",
+ "primary-foreground": "hsl(var(--primary-foreground, 230 16% 9%) / )",
},
focusStyles: {
outline: "1px solid",