diff --git a/.changeset/ai-chat-sandbox-and-ctx.md b/.changeset/ai-chat-sandbox-and-ctx.md new file mode 100644 index 00000000000..7f453392655 --- /dev/null +++ b/.changeset/ai-chat-sandbox-and-ctx.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/sdk": patch +--- + +Add `TaskRunContext` (`ctx`) to all `chat.task` lifecycle events, `CompactedEvent`, and `ChatTaskRunPayload`. Export `TaskRunContext` from `@trigger.dev/sdk`. + diff --git a/.changeset/ai-sdk-chat-transport.md b/.changeset/ai-sdk-chat-transport.md new file mode 100644 index 00000000000..f5cdb9187d4 --- /dev/null +++ b/.changeset/ai-sdk-chat-transport.md @@ -0,0 +1,42 @@ +--- +"@trigger.dev/sdk": minor +--- + +Add AI SDK chat transport integration via two new subpath exports: + +**`@trigger.dev/sdk/chat`** (frontend, browser-safe): +- `TriggerChatTransport` — custom `ChatTransport` for the AI SDK's `useChat` hook that runs chat completions as durable Trigger.dev tasks +- `createChatTransport()` — factory function + +```tsx +import { useChat } from "@ai-sdk/react"; +import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; + +const { messages, sendMessage } = useChat({ + transport: new TriggerChatTransport({ + task: "my-chat-task", + accessToken, + }), +}); +``` + +**`@trigger.dev/sdk/ai`** (backend, extends existing `ai.tool`/`ai.currentToolOptions`): +- `chatTask()` — pre-typed task wrapper with auto-pipe support +- `pipeChat()` — pipe a `StreamTextResult` or stream to the frontend +- `CHAT_STREAM_KEY` — the default stream key constant +- `ChatTaskPayload` type + +```ts +import { chatTask } from "@trigger.dev/sdk/ai"; +import { streamText, convertToModelMessages } from "ai"; + +export const myChatTask = chatTask({ + id: "my-chat-task", + run: async ({ messages }) => { + return streamText({ + model: openai("gpt-4o"), + messages: convertToModelMessages(messages), + }); + }, +}); +``` diff --git a/.changeset/ai-tool-execute-helper.md b/.changeset/ai-tool-execute-helper.md new file mode 100644 index 00000000000..6f7b8914504 --- /dev/null +++ b/.changeset/ai-tool-execute-helper.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Add `ai.toolExecute(task)` so you can pass Trigger's subtask/metadata wiring as the `execute` handler to AI SDK `tool()` while defining `description` and `inputSchema` yourself. Refactors `ai.tool()` to share the same internal handler. diff --git a/.changeset/ai-tool-toolset-typing.md b/.changeset/ai-tool-toolset-typing.md new file mode 100644 index 00000000000..de67be637f3 --- /dev/null +++ b/.changeset/ai-tool-toolset-typing.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/sdk": patch +--- + +Align `ai.tool()` (`toolFromTask`) with the AI SDK `ToolSet` shape: Zod-backed tasks use static `tool()`; returns are asserted as `Tool & ToolSet[string]`. Raise the SDK's minimum `ai` devDependency to `^6.0.116` so emitted types resolve the same `ToolSet` as apps on AI SDK 6.0.x (avoids cross-version `ToolSet` mismatches in monorepos). + diff --git a/.changeset/chat-run-pat-renewal.md b/.changeset/chat-run-pat-renewal.md new file mode 100644 index 00000000000..8d4b6cb80ea --- /dev/null +++ b/.changeset/chat-run-pat-renewal.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"@trigger.dev/sdk": patch +--- + +Add run-scoped PAT renewal for chat transport (`renewRunAccessToken`), fail fast on 401/403 for SSE without retry backoff, and export `isTriggerRealtimeAuthError` for auth-error detection. diff --git a/.changeset/dry-sloths-divide.md b/.changeset/dry-sloths-divide.md new file mode 100644 index 00000000000..31e7ec9b941 --- /dev/null +++ b/.changeset/dry-sloths-divide.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Add `chat.withUIMessage()` for typed AI SDK `UIMessage` in chat task hooks, optional factory `streamOptions` merged with `uiMessageStreamOptions`, and `InferChatUIMessage` helper. Generic `ChatUIMessageStreamOptions`, compaction, and pending-message event types. `usePendingMessages` accepts a UI message type parameter; re-export `InferChatUIMessage` from `@trigger.dev/sdk/chat/react`. diff --git a/.changeset/trigger-chat-transport-watch-mode.md b/.changeset/trigger-chat-transport-watch-mode.md new file mode 100644 index 00000000000..1b2346d859e --- /dev/null +++ b/.changeset/trigger-chat-transport-watch-mode.md @@ -0,0 +1,23 @@ +--- +"@trigger.dev/sdk": patch +--- + +Add `watch` option to `TriggerChatTransport` for read-only observation of an existing chat run. + +When set to `true`, the transport keeps its internal `ReadableStream` open across `trigger:turn-complete` control chunks instead of closing it after each turn. This lets a single `useChat` / `resumeStream` subscription observe every turn of a long-lived agent run — useful for dashboard viewers or debug UIs that only want to watch an existing conversation as it unfolds, rather than drive it. + +```tsx +const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: runScopedPat, + watch: true, + sessions: { + [chatId]: { runId, publicAccessToken: runScopedPat }, + }, +}); + +const { messages, resumeStream } = useChat({ id: chatId, transport }); +useEffect(() => { resumeStream(); }, [resumeStream]); +``` + +Non-watch transports are unaffected — the default remains `false` and existing behavior (close on turn-complete so `useChat` can flip to `"ready"` between turns) is preserved for interactive playground-style flows. diff --git a/.claude/rules/package-installation.md b/.claude/rules/package-installation.md new file mode 100644 index 00000000000..310074823c5 --- /dev/null +++ b/.claude/rules/package-installation.md @@ -0,0 +1,22 @@ +--- +paths: + - "**/package.json" +--- + +# Installing Packages + +When adding a new dependency to any package.json in the monorepo: + +1. **Look up the latest version** on npm before adding: + ```bash + pnpm view version + ``` + If unsure which version to use (e.g. major version compatibility), confirm with the user. + +2. **Edit the package.json directly** — do NOT use `pnpm add` as it can cause issues in the monorepo. Add the dependency with the correct version range (typically `^x.y.z`). + +3. **Run `pnpm i` from the repo root** after editing to install and update the lockfile: + ```bash + pnpm i + ``` + Always run from the repo root, not from the package directory. diff --git a/.server-changes/run-agent-view.md b/.server-changes/run-agent-view.md new file mode 100644 index 00000000000..76580157d60 --- /dev/null +++ b/.server-changes/run-agent-view.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add an Agent view to the run details page for runs whose `taskKind` annotation is `AGENT`. The view renders the agent's `UIMessage` conversation by subscribing to the run's `chat` realtime stream — the same data source as the Agent Playground content view. Switching is via a `Trace view` / `Agent view` segmented control above the run body, and the selected view is reflected in the URL via `?view=agent` so it's shareable. diff --git a/.server-changes/streamdown-v2-upgrade.md b/.server-changes/streamdown-v2-upgrade.md new file mode 100644 index 00000000000..8a0b3f17af0 --- /dev/null +++ b/.server-changes/streamdown-v2-upgrade.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Upgrade streamdown from v1.4.0 to v2.5.0. Custom Shiki syntax highlighting theme matching our CodeMirror dark theme colors. Consolidate duplicated lazy StreamdownRenderer into a shared component. diff --git a/CLAUDE.md b/CLAUDE.md index 0a54cced672..2fb7a4b67a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code when working with this repository. Su This is a pnpm 10.23.0 monorepo using Turborepo. Run commands from root with `pnpm run`. +**Adding dependencies:** Edit `package.json` directly instead of using `pnpm add`, then run `pnpm i` from the repo root. See `.claude/rules/package-installation.md` for the full process. + ```bash pnpm run docker # Start Docker services (PostgreSQL, Redis, Electric) pnpm run db:migrate # Run database migrations diff --git a/apps/webapp/app/components/BulkActionFilterSummary.tsx b/apps/webapp/app/components/BulkActionFilterSummary.tsx index a230e70b346..c5d1a2f48d7 100644 --- a/apps/webapp/app/components/BulkActionFilterSummary.tsx +++ b/apps/webapp/app/components/BulkActionFilterSummary.tsx @@ -240,6 +240,19 @@ export function BulkActionFilterSummary({ /> ); } + case "sources": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } default: { assertNever(typedKey); } diff --git a/apps/webapp/app/components/code/AIQueryInput.tsx b/apps/webapp/app/components/code/AIQueryInput.tsx index 0775ec2c2a0..cd5e9db3bd8 100644 --- a/apps/webapp/app/components/code/AIQueryInput.tsx +++ b/apps/webapp/app/components/code/AIQueryInput.tsx @@ -1,25 +1,15 @@ import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { AnimatePresence, motion } from "framer-motion"; -import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"; +import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Button } from "~/components/primitives/Buttons"; import { Spinner } from "~/components/primitives/Spinner"; +import { StreamdownRenderer } from "~/components/code/StreamdownRenderer"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types"; import { cn } from "~/utils/cn"; -// Lazy load streamdown components to avoid SSR issues -const StreamdownRenderer = lazy(() => - import("streamdown").then((mod) => ({ - default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => ( - - {children} - - ), - })) -); - type StreamEventType = | { type: "thinking"; content: string } | { type: "tool_call"; tool: string; args: unknown } diff --git a/apps/webapp/app/components/code/StreamdownRenderer.tsx b/apps/webapp/app/components/code/StreamdownRenderer.tsx new file mode 100644 index 00000000000..996234ab180 --- /dev/null +++ b/apps/webapp/app/components/code/StreamdownRenderer.tsx @@ -0,0 +1,29 @@ +import { lazy } from "react"; +import type { CodeHighlighterPlugin } from "streamdown"; + +export const StreamdownRenderer = lazy(() => + Promise.all([import("streamdown"), import("@streamdown/code"), import("./shikiTheme")]).then( + ([{ Streamdown }, { createCodePlugin }, { triggerDarkTheme }]) => { + // Type assertion needed: @streamdown/code and streamdown resolve different shiki + // versions under pnpm, causing structurally-identical CodeHighlighterPlugin types + // to be considered incompatible (different BundledLanguage string unions). + const codePlugin = createCodePlugin({ + themes: [triggerDarkTheme, triggerDarkTheme], + }) as unknown as CodeHighlighterPlugin; + + return { + default: ({ + children, + isAnimating = false, + }: { + children: string; + isAnimating?: boolean; + }) => ( + + {children} + + ), + }; + } + ) +); diff --git a/apps/webapp/app/components/code/shikiTheme.ts b/apps/webapp/app/components/code/shikiTheme.ts new file mode 100644 index 00000000000..5d47155b979 --- /dev/null +++ b/apps/webapp/app/components/code/shikiTheme.ts @@ -0,0 +1,222 @@ +import type { ThemeRegistrationAny } from "streamdown"; + +// Custom Shiki theme matching the Trigger.dev VS Code dark theme. +// Colors taken directly from the VS Code extension's tokenColors. +export const triggerDarkTheme: ThemeRegistrationAny = { + name: "trigger-dark", + type: "dark", + colors: { + "editor.background": "#212327", + "editor.foreground": "#878C99", + "editorLineNumber.foreground": "#484c54", + }, + tokenColors: [ + // Control flow keywords: pink-purple + { + scope: [ + "keyword.control", + "keyword.operator.delete", + "keyword.other.using", + "keyword.other.operator", + "entity.name.operator", + ], + settings: { foreground: "#E888F8" }, + }, + // Storage type (const, let, var, function, class): purple + { + scope: "storage.type", + settings: { foreground: "#8271ED" }, + }, + // Storage modifiers (async, export, etc.): purple + { + scope: ["storage.modifier", "keyword.operator.noexcept"], + settings: { foreground: "#8271ED" }, + }, + // Keyword operator expressions (new, typeof, instanceof, etc.): purple + { + scope: [ + "keyword.operator.new", + "keyword.operator.expression", + "keyword.operator.cast", + "keyword.operator.sizeof", + "keyword.operator.instanceof", + "keyword.operator.logical.python", + "keyword.operator.wordlike", + ], + settings: { foreground: "#8271ED" }, + }, + // Types and namespaces: hot pink + { + scope: [ + "support.class", + "support.type", + "entity.name.type", + "entity.name.namespace", + "entity.name.scope-resolution", + "entity.name.class", + "entity.other.inherited-class", + ], + settings: { foreground: "#F770C6" }, + }, + // Functions: lime/yellow-green + { + scope: ["entity.name.function", "support.function"], + settings: { foreground: "#D9F07C" }, + }, + // Variables and parameters: light lavender + { + scope: [ + "variable", + "meta.definition.variable.name", + "support.variable", + "entity.name.variable", + "constant.other.placeholder", + ], + settings: { foreground: "#CCCBFF" }, + }, + // Constants and enums: medium purple + { + scope: ["variable.other.constant", "variable.other.enummember"], + settings: { foreground: "#9C9AF2" }, + }, + // this/self: purple-blue + { + scope: "variable.language", + settings: { foreground: "#9B99FF" }, + }, + // Object literal keys: medium purple-blue + { + scope: "meta.object-literal.key", + settings: { foreground: "#8B89FF" }, + }, + // Strings: sage green + { + scope: ["string", "meta.embedded.assembly"], + settings: { foreground: "#AFEC73" }, + }, + // String interpolation punctuation: blue-purple + { + scope: [ + "punctuation.definition.template-expression.begin", + "punctuation.definition.template-expression.end", + "punctuation.section.embedded", + ], + settings: { foreground: "#7A78EA" }, + }, + // Template expression reset + { + scope: "meta.template.expression", + settings: { foreground: "#d4d4d4" }, + }, + // Operators: gray (same as foreground) + { + scope: "keyword.operator", + settings: { foreground: "#878C99" }, + }, + // Comments: olive gray + { + scope: "comment", + settings: { foreground: "#6f736d" }, + }, + // Language constants (true, false, null, undefined): purple-blue + { + scope: "constant.language", + settings: { foreground: "#9B99FF" }, + }, + // Numeric constants: light green + { + scope: [ + "constant.numeric", + "keyword.operator.plus.exponent", + "keyword.operator.minus.exponent", + ], + settings: { foreground: "#b5cea8" }, + }, + // Regex: dark red + { + scope: "constant.regexp", + settings: { foreground: "#646695" }, + }, + // HTML/JSX tags: purple-blue + { + scope: "entity.name.tag", + settings: { foreground: "#9B99FF" }, + }, + // Tag brackets: dark gray + { + scope: "punctuation.definition.tag", + settings: { foreground: "#5F6570" }, + }, + // HTML/JSX attributes: light purple + { + scope: "entity.other.attribute-name", + settings: { foreground: "#C39EFF" }, + }, + // Escape characters: gold + { + scope: "constant.character.escape", + settings: { foreground: "#d7ba7d" }, + }, + // Regex string: dark red + { + scope: "string.regexp", + settings: { foreground: "#d16969" }, + }, + // Storage: purple-blue + { + scope: "storage", + settings: { foreground: "#9B99FF" }, + }, + // TS-specific: type casts, math/dom/json constants + { + scope: [ + "meta.type.cast.expr", + "meta.type.new.expr", + "support.constant.math", + "support.constant.dom", + "support.constant.json", + ], + settings: { foreground: "#9B99FF" }, + }, + // Markdown headings: purple-blue bold + { + scope: "markup.heading", + settings: { foreground: "#9B99FF", fontStyle: "bold" }, + }, + // Markup bold: purple-blue + { + scope: "markup.bold", + settings: { foreground: "#9B99FF", fontStyle: "bold" }, + }, + // Markup inline raw: sage green + { + scope: "markup.inline.raw", + settings: { foreground: "#AFEC73" }, + }, + // Markup inserted: light green + { + scope: "markup.inserted", + settings: { foreground: "#b5cea8" }, + }, + // Markup deleted: sage green + { + scope: "markup.deleted", + settings: { foreground: "#AFEC73" }, + }, + // Markup changed: purple-blue + { + scope: "markup.changed", + settings: { foreground: "#9B99FF" }, + }, + // Invalid: red + { + scope: "invalid", + settings: { foreground: "#f44747" }, + }, + // JSX text content + { + scope: ["meta.jsx.children"], + settings: { foreground: "#D7D9DD" }, + }, + ], +}; diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 1169343e9d3..9948e23cac2 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -10,6 +10,7 @@ import { ClockIcon, Cog8ToothIcon, CogIcon, + CpuChipIcon, CubeIcon, ExclamationTriangleIcon, FolderIcon, @@ -69,7 +70,9 @@ import { organizationTeamPath, queryPath, regionsPath, + v3AgentsPath, v3ApiKeysPath, + v3PlaygroundPath, v3BatchesPath, v3BillingPath, v3BuiltInDashboardPath, @@ -467,6 +470,22 @@ export function SideMenu({ initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "ai")} onCollapseToggle={handleSectionToggle("ai")} > + + - import("streamdown").then((mod) => ({ - default: ({ children }: { children: string }) => ( - - {children} - - ), - })) -); - type PromptTab = "overview" | "input" | "template"; export function PromptSpanDetails({ diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index dc3657b42a9..6ddca57e6d6 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -2,6 +2,7 @@ import * as Ariakit from "@ariakit/react"; import { CalendarIcon, ClockIcon, + CpuChipIcon, FingerPrintIcon, RectangleStackIcon, Squares2X2Icon, @@ -182,6 +183,9 @@ export const TaskRunListSearchFilters = z.object({ `Machine presets to filter by (${machines.join(", ")})` ), errorId: z.string().optional().describe("Error ID to filter runs by (e.g. error_abc123)"), + sources: StringOrStringArray.describe( + "Task trigger sources to filter by (STANDARD, SCHEDULED, AGENT)" + ), }); export type TaskRunListSearchFilters = z.infer; @@ -223,6 +227,8 @@ export function filterTitle(filterKey: string) { return "Version"; case "errorId": return "Error ID"; + case "sources": + return "Source"; default: return filterKey; } @@ -263,6 +269,8 @@ export function filterIcon(filterKey: string): ReactNode | undefined { return ; case "errorId": return ; + case "sources": + return ; default: return undefined; } @@ -310,6 +318,10 @@ export function getRunFiltersFromSearchParams( ? searchParams.getAll("versions") : undefined, errorId: searchParams.get("errorId") ?? undefined, + sources: + searchParams.getAll("sources").filter((v) => v.length > 0).length > 0 + ? searchParams.getAll("sources") + : undefined, }; const parsed = TaskRunListSearchFilters.safeParse(params); @@ -351,7 +363,8 @@ export function RunsFilters(props: RunFiltersProps) { searchParams.has("queues") || searchParams.has("machines") || searchParams.has("versions") || - searchParams.has("errorId"); + searchParams.has("errorId") || + searchParams.has("sources"); return (
@@ -388,6 +401,7 @@ const filterTypes = [ { name: "schedule", title: "Schedule ID", icon: }, { name: "bulk", title: "Bulk action", icon: }, { name: "error", title: "Error ID", icon: }, + { name: "source", title: "Source", icon: }, ] as const; type FilterType = (typeof filterTypes)[number]["name"]; @@ -443,6 +457,7 @@ function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) { + ); } @@ -481,6 +496,8 @@ function Menu(props: MenuProps) { return props.setFilterType(undefined)} {...props} />; case "error": return props.setFilterType(undefined)} {...props} />; + case "source": + return props.setFilterType(undefined)} {...props} />; } } @@ -1869,3 +1886,101 @@ function AppliedErrorIdFilter() { ); } + +const sourceOptions: { value: TaskTriggerSource; title: string }[] = [ + { value: "STANDARD", title: "Standard" }, + { value: "SCHEDULED", title: "Scheduled" }, + { value: "AGENT", title: "Agent" }, +]; + +function SourceDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ sources: values, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return sourceOptions.filter((item) => + item.title.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + > + + + {filtered.map((item, index) => ( + + } + shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} + > + {item.title} + + ))} + + + + ); +} + +function AppliedSourceFilter() { + const { values, del } = useSearchParams(); + const sources = values("sources"); + + if (sources.length === 0 || sources.every((v) => v === "")) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + } + value={appliedSummary( + sources.map( + (v) => sourceOptions.find((o) => o.value === v)?.title ?? v + ) + )} + onRemove={() => del(["sources", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index fbede0e7cec..2d14127199c 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -55,8 +55,10 @@ import { filterableTaskRunStatuses, TaskRunStatusCombo, } from "./TaskRunStatus"; +import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useSearchParams } from "~/hooks/useSearchParam"; +import type { TaskTriggerSource } from "@trigger.dev/database"; type RunsTableProps = { total: number; @@ -343,6 +345,10 @@ export function TaskRunsTable({ + {run.taskIdentifier} {run.rootTaskRunId === null ? Root : null} diff --git a/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx b/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx index 8d81e2f36c3..dc61644e14c 100644 --- a/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx +++ b/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx @@ -1,4 +1,4 @@ -import { ClockIcon } from "@heroicons/react/20/solid"; +import { ClockIcon, CpuChipIcon } from "@heroicons/react/20/solid"; import type { TaskTriggerSource } from "@trigger.dev/database"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; import { cn } from "~/utils/cn"; @@ -19,6 +19,11 @@ export function TaskTriggerSourceIcon({ ); } + case "AGENT": { + return ( + + ); + } } } @@ -30,5 +35,8 @@ export function taskTriggerSourceDescription(source: TaskTriggerSource) { case "SCHEDULED": { return "Scheduled task"; } + case "AGENT": { + return "Agent"; + } } } diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx new file mode 100644 index 00000000000..72f019bb59f --- /dev/null +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -0,0 +1,246 @@ +import type { UIMessage } from "@ai-sdk/react"; +import { memo } from "react"; +import { + AssistantResponse, + ChatBubble, + ToolUseRow, +} from "~/components/runs/v3/ai/AIChatMessages"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; + +// --------------------------------------------------------------------------- +// AgentMessageView — renders an AI SDK UIMessage[] conversation. +// +// Extracted from the playground route so it can be reused on the run details +// page when the user picks the Agent view. +// +// UIMessage part types (AI SDK): +// text — markdown text content +// reasoning — model reasoning/thinking +// tool-{name} — tool call with input/output/state +// source-url — citation link +// source-document — citation document reference +// file — file attachment (image, etc.) +// step-start — visual separator between steps +// data-{name} — custom data parts (rendered as a small popover) +// --------------------------------------------------------------------------- + +export function AgentMessageView({ messages }: { messages: UIMessage[] }) { + return ( +
+ {messages.map((msg) => ( + + ))} +
+ ); +} + +// Memoized so stable messages (anything older than the one currently +// streaming) don't re-render on every chunk. This matters a lot during +// `resumeStream()` history replay, where each re-render would otherwise +// re-run Prism highlighting on every tool-call CodeBlock in the list. +// +// Default shallow prop comparison is fine: AI SDK's useChat keeps stable +// references for messages that haven't changed, so only the last message +// (the one receiving new chunks) re-renders. +export const MessageBubble = memo(function MessageBubble({ + message, +}: { + message: UIMessage; +}) { + if (message.role === "user") { + const text = + message.parts + ?.filter((p) => p.type === "text") + .map((p) => (p as { type: "text"; text: string }).text) + .join("") ?? ""; + + return ( +
+
+
{text}
+
+
+ ); + } + + if (message.role === "assistant") { + const hasContent = message.parts && message.parts.length > 0; + if (!hasContent) return null; + + return ( +
+ {message.parts?.map((part, i) => renderPart(part, i))} +
+ ); + } + + return null; +}); + +export function renderPart(part: UIMessage["parts"][number], i: number) { + const p = part as any; + const type = part.type as string; + + // Text — markdown rendered via AssistantResponse + if (type === "text") { + return p.text ? : null; + } + + // Reasoning — amber-bordered italic block + if (type === "reasoning") { + return ( +
+ +
+ {p.text ?? ""} +
+
+
+ ); + } + + // Tool call — type: "tool-{name}" with toolCallId, input, output, state + if (type.startsWith("tool-")) { + const toolName = type.slice(5); + + // Sub-agent tool: output is a UIMessage with parts + const isSubAgent = + p.output != null && typeof p.output === "object" && Array.isArray(p.output.parts); + + // For sub-agent tools, show the last text part as the "output" tab + // (mirrors what toModelOutput typically sends to the parent LLM) + // instead of dumping the full UIMessage JSON. + let resultOutput: string | undefined; + if (isSubAgent) { + const lastText = (p.output.parts as any[]) + .filter((part: any) => part.type === "text" && part.text) + .pop(); + resultOutput = lastText?.text ?? undefined; + } else if (p.output != null) { + resultOutput = + typeof p.output === "string" ? p.output : JSON.stringify(p.output, null, 2); + } + + return ( + + ); + } + + // Source URL — clickable citation link + if (type === "source-url") { + return ( + + ); + } + + // Source document — citation label + if (type === "source-document") { + return ( +
+ {p.title} + {p.mediaType ? ` (${p.mediaType})` : ""} +
+ ); + } + + // File — render as image if image type, otherwise as download link + if (type === "file") { + const isImage = typeof p.mediaType === "string" && p.mediaType.startsWith("image/"); + if (isImage) { + return ( + {p.filename + ); + } + return ( + + ); + } + + // Step start — subtle dashed separator with centered label + if (type === "step-start") { + return ( +
+
+ step +
+
+ ); + } + + // Data parts — type: "data-{name}", show as labeled JSON popover + if (type.startsWith("data-")) { + const dataName = type.slice(5); + return ; + } + + return null; +} + +function DataPartPopover({ name, data }: { name: string; data: unknown }) { + const formatted = JSON.stringify(data, null, 2); + + return ( + + + + + +
+ data-{name} +
+
+
{formatted}
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/agent/AgentView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx new file mode 100644 index 00000000000..fd3dfb5cbf3 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx @@ -0,0 +1,701 @@ +import type { UIMessage } from "@ai-sdk/react"; +import { SSEStreamSubscription } from "@trigger.dev/core/v3"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; +import { AgentMessageView } from "~/components/runs/v3/agent/AgentMessageView"; +import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; + +export type AgentViewAuth = { + publicAccessToken: string; + apiOrigin: string; + /** + * User messages extracted from the run's task payload at load time. + * Empty array for runs started with `trigger: "preload"` — in that case + * the first user message will arrive over the chat-messages input stream + * and get merged in by the AgentView subscription. + */ + initialMessages: UIMessage[]; +}; + +type AgentViewRun = { + friendlyId: string; + taskIdentifier: string; +}; + +// Default stream IDs for Trigger.dev chat tasks — kept as literals so we +// don't pull server-only constants from `@trigger.dev/core/v3/chat-client` +// into a browser bundle. +const CHAT_STREAM_KEY = "chat"; +const CHAT_MESSAGES_STREAM_ID = "chat-messages"; + +/** + * Max state-update interval while assistant chunks are streaming. Matches + * the `experimental_throttle: 100` we previously passed to `useChat`. + * Chunks mutate a staging ref synchronously; a throttled flush copies the + * ref into React state at most ~10x/sec so tool-call Prism highlighting + * etc. doesn't re-run on every single text-delta. + */ +const STATE_FLUSH_THROTTLE_MS = 100; + +/** + * Sentinel timestamp for messages that came from the run's initial task + * payload — they predate any stream activity, so 0 guarantees they sort + * first regardless of stream race order. + */ +const INITIAL_PAYLOAD_TIMESTAMP = 0; + +/** + * Renders an agent run's chat conversation as it unfolds. + * + * Subscribes to two separate realtime streams for the run: + * - The **chat output stream** delivers assistant `UIMessageChunk`s (text + * deltas, tool calls, reasoning, etc.) produced by `pipeChat` on the + * task side. + * - The **chat-messages input stream** delivers user messages sent to the + * task via `sendInputStream` — each chunk carries a `ChatTaskWirePayload` + * with the most recent `messages` array. + * + * Both streams are read directly via `SSEStreamSubscription` through the + * dashboard's session-authed resource routes — not through `useChat` or + * `TriggerChatTransport`. This gives us per-chunk server-side timestamps + * (Redis stream IDs) from both streams, which we use to produce a + * chronologically correct merged message list that works for replays, + * multi-message turns, and steering messages. + * + * Intended to be mounted inside a scrollable container — the component + * does not own its own scrollbar. + */ +export function AgentView({ + run, + agentView, +}: { + run: AgentViewRun; + agentView: AgentViewAuth; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const messages = useAgentRunMessages({ + runFriendlyId: run.friendlyId, + apiOrigin: agentView.apiOrigin, + orgSlug: organization.slug, + projectSlug: project.slug, + envSlug: environment.slug, + initialMessages: agentView.initialMessages, + }); + + // Sticky-bottom auto-scroll: walks up to find the inspector's scroll + // container, then scrolls to bottom whenever `messages` changes — but + // only if the user was at (or near) the bottom at the time. Scrolling + // away pauses auto-scroll; scrolling back resumes it. + const rootRef = useAutoScrollToBottom([messages]); + + return ( +
+ {messages.length === 0 ? ( +
+
+ + + Loading conversation… + +
+
+ ) : ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// useAgentRunMessages — reads both realtime streams for a run and maintains +// a chronologically ordered, merged message list. +// --------------------------------------------------------------------------- + +/** + * Shape of each chunk on the chat-messages input stream. Each chunk is a + * `ChatTaskWirePayload` whose `messages` field holds either the latest user + * message (for `submit-message`) or the full history (for + * `regenerate-message`). We dedupe by ID either way. + */ +type InputStreamChunk = { + messages?: Array<{ id?: string; role?: string; parts?: unknown[] }>; + trigger?: string; +}; + +/** + * Minimal typing for the chunks we care about on the chat output stream. + * Covers the AI SDK `UIMessageChunk` variants that `renderPart` actually + * knows how to display, plus the Trigger.dev control chunks that we filter. + */ +type OutputChunk = { type: string; [key: string]: unknown }; + +/** + * Per-message orchestration state for the output stream accumulator. Mirrors + * the active-part tracking that AI SDK's `processUIMessageStream` keeps in + * its `state` object: a registry of streaming text/reasoning parts so deltas + * can be matched to the right part by id, plus a way to clear them at step + * boundaries (`finish-step`) so the next step's `text-start`/`reasoning-start` + * with the same id starts a fresh part instead of appending to the previous + * step's part. + */ +/** + * Per-message orchestration state — index-based active-part tracking. + * + * Each map points from a part id (text or reasoning) to **the index of the + * currently-streaming part with that id in `message.parts`**. We need + * indexes (not just a `Set` of "active ids") because part ids are *only + * unique within a step*: the SDK happily reuses `text-start id="0"` after + * a `finish-step` boundary. Without index tracking, a `text-delta` for the + * reused id would have to find the right part by id alone — and a search + * would match BOTH the previous step's frozen part and the current step's + * fresh one, which produces a duplication where the previous text gets + * the new content appended to it AND a fresh part with the same content + * also appears. + * + * Mirrors AI SDK's `processUIMessageStream`'s `state.activeTextParts` / + * `state.activeReasoningParts` (which hold direct references in the + * mutating canonical impl). We use indexes here because we do immutable + * updates and need indices that survive `parts.map()` rewrites — adding + * new parts and updating existing ones never reorders, so an index is + * stable for the lifetime of the part. + */ +type MessageOrchestrationState = { + activeTextPartIndexes: Map; + activeReasoningPartIndexes: Map; +}; + +/** + * `SSEStreamSubscription`'s v2 batch path delivers `parsedBody.data` as-is + * — but for streams written via `sendInputStream` (which stores the user + * payload as a JSON string in the record body), `data` is itself a string + * that needs a second `JSON.parse` to recover the actual object. This + * happens for the chat-messages input stream because the action handler + * does `JSON.stringify(body.data.data)` before storing. + * + * Output streams from `pipeChat` write objects directly, so the v2 path + * delivers them already-parsed. Either way this helper accepts both shapes + * defensively: a string is parsed; an object is returned as-is. + * + * Returns `null` for unparseable / unexpected payloads. + */ +function parseChunkPayload(raw: unknown): Record | null { + if (raw == null) return null; + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : null; + } catch { + return null; + } + } + if (typeof raw === "object") return raw as Record; + return null; +} + +function createOrchestrationState(): MessageOrchestrationState { + return { + activeTextPartIndexes: new Map(), + activeReasoningPartIndexes: new Map(), + }; +} + +function useAgentRunMessages({ + runFriendlyId, + apiOrigin, + orgSlug, + projectSlug, + envSlug, + initialMessages, +}: { + runFriendlyId: string; + apiOrigin: string; + orgSlug: string; + projectSlug: string; + envSlug: string; + initialMessages: UIMessage[]; +}): UIMessage[] { + // Seed with the user messages from the run's task payload. + const seedMessages = useMemo( + () => initialMessages.filter((m) => m.role === "user"), + [initialMessages] + ); + + // `pendingRef` is the authoritative, eagerly-updated message state: + // chunks mutate this synchronously as they arrive. A throttled flush + // copies it into React state so UI updates are capped at ~10x/sec. + const pendingRef = useRef>( + new Map(seedMessages.map((m) => [m.id, m])) + ); + const timestampsRef = useRef>( + new Map(seedMessages.map((m) => [m.id, INITIAL_PAYLOAD_TIMESTAMP])) + ); + // Side-table of orchestration state, keyed by assistant message id. Lives + // outside the UIMessage so React doesn't see it as a renderable prop. + const orchestrationRef = useRef>(new Map()); + + // React state snapshot of pendingRef. Only updated via the throttled + // `scheduleFlush`. The Map *reference* changes on every flush so React + // detects the state update and the downstream `useMemo` recomputes. + const [messagesById, setMessagesById] = useState>( + () => new Map(pendingRef.current) + ); + + // Throttled flush scheduler — leading edge within a single throttle + // window: the first chunk after a quiet period flushes immediately, then + // subsequent chunks coalesce until the next window opens. + const lastFlushAtRef = useRef(0); + const pendingTimerRef = useRef | null>(null); + const scheduleFlush = useRef<() => void>(() => {}); + scheduleFlush.current = () => { + if (pendingTimerRef.current !== null) return; // already scheduled + const now = Date.now(); + const sinceLast = now - lastFlushAtRef.current; + const delay = Math.max(0, STATE_FLUSH_THROTTLE_MS - sinceLast); + pendingTimerRef.current = setTimeout(() => { + pendingTimerRef.current = null; + lastFlushAtRef.current = Date.now(); + setMessagesById(new Map(pendingRef.current)); + }, delay); + }; + + useEffect(() => { + const abort = new AbortController(); + + const outputUrl = + `${apiOrigin}/resources/orgs/${orgSlug}/projects/${projectSlug}/env/${envSlug}` + + `/runs/${runFriendlyId}/realtime/v1/streams/${runFriendlyId}/${CHAT_STREAM_KEY}`; + + const inputUrl = + `${apiOrigin}/resources/orgs/${orgSlug}/projects/${projectSlug}/env/${envSlug}` + + `/runs/${runFriendlyId}/realtime/v1/streams/${runFriendlyId}/input/${CHAT_MESSAGES_STREAM_ID}`; + + const commonSubOptions = { + signal: abort.signal, + timeoutInSeconds: 120, + } as const; + + // ---- Output stream: assistant messages --------------------------------- + // + // The output stream delivers UIMessageChunks interleaved with + // Trigger-specific control chunks (`trigger:turn-complete`, etc.). We + // filter the control chunks and fold everything else into an assistant + // `UIMessage` via our own `applyOutputChunk` accumulator — the AI SDK's + // `readUIMessageStream` helper is only available in `ai@6`, and the + // webapp is pinned to `ai@4`, so we re-implement just the chunk types + // that `renderPart` actually displays. + // + // We capture the **server timestamp of each assistant message's first + // `start` chunk** so later sort-by-timestamp merges with the input + // stream correctly. + const runOutput = async () => { + try { + const sub = new SSEStreamSubscription(outputUrl, commonSubOptions); + const raw = await sub.subscribe(); + const reader = raw.getReader(); + + let currentMessageId: string | null = null; + + try { + while (!abort.signal.aborted) { + const { done, value } = await reader.read(); + if (done) return; + + const chunk = parseChunkPayload(value.chunk) as OutputChunk | null; + if (!chunk || typeof chunk.type !== "string") continue; + if (chunk.type.startsWith("trigger:")) continue; + + if (chunk.type === "start") { + const messageId = + typeof chunk.messageId === "string" && chunk.messageId.length > 0 + ? chunk.messageId + : `asst-${crypto.randomUUID()}`; + currentMessageId = messageId; + + if (!timestampsRef.current.has(messageId)) { + timestampsRef.current.set(messageId, value.timestamp); + } + + const existing = pendingRef.current.get(messageId); + if (existing) { + // Same message id seen again — merge metadata only, keep + // existing parts (canonical `processUIMessageStream` does + // the same on a repeated `start`). + if (chunk.messageMetadata != null) { + pendingRef.current.set(messageId, { + ...existing, + metadata: { + ...((existing as { metadata?: Record }).metadata ?? {}), + ...(chunk.messageMetadata as Record), + }, + } as UIMessage); + scheduleFlush.current(); + } + } else { + const message: UIMessage = { + id: messageId, + role: "assistant", + parts: [], + ...(chunk.messageMetadata != null + ? { metadata: chunk.messageMetadata as UIMessage["metadata"] } + : {}), + } as UIMessage; + pendingRef.current.set(messageId, message); + orchestrationRef.current.set(messageId, createOrchestrationState()); + scheduleFlush.current(); + } + continue; + } + + if (currentMessageId === null) continue; + const existing = pendingRef.current.get(currentMessageId); + if (!existing) continue; + let orchestration = orchestrationRef.current.get(currentMessageId); + if (!orchestration) { + // Defensive: a chunk arrived for a message we never saw a + // `start` for. Lazily create orchestration state so we can + // still display the parts. + orchestration = createOrchestrationState(); + orchestrationRef.current.set(currentMessageId, orchestration); + } + + const updated = applyOutputChunk(existing, chunk, orchestration); + if (updated !== existing) { + pendingRef.current.set(currentMessageId, updated); + scheduleFlush.current(); + } + } + } finally { + try { + reader.releaseLock(); + } catch { + // Lock may already be released. + } + } + } catch (err) { + if (abort.signal.aborted) return; + // eslint-disable-next-line no-console + console.debug("[AgentView] output stream subscription failed", err); + } + }; + + // ---- Input stream: user messages --------------------------------------- + const runInput = async () => { + try { + const sub = new SSEStreamSubscription(inputUrl, commonSubOptions); + const raw = await sub.subscribe(); + const reader = raw.getReader(); + try { + while (!abort.signal.aborted) { + const { done, value } = await reader.read(); + if (done) return; + + const chunk = parseChunkPayload(value.chunk) as InputStreamChunk | null; + if (!chunk || !Array.isArray(chunk.messages)) continue; + + const incomingUsers = chunk.messages.filter( + (m): m is UIMessage => + m != null && (m as { role?: string }).role === "user" && typeof m.id === "string" + ); + if (incomingUsers.length === 0) continue; + + let changed = false; + for (const msg of incomingUsers) { + if (pendingRef.current.has(msg.id)) continue; + pendingRef.current.set(msg.id, msg); + timestampsRef.current.set(msg.id, value.timestamp); + changed = true; + } + if (changed) scheduleFlush.current(); + } + } finally { + try { + reader.releaseLock(); + } catch { + // Lock may already be released. + } + } + } catch (err) { + if (abort.signal.aborted) return; + // eslint-disable-next-line no-console + console.debug("[AgentView] input stream subscription failed", err); + } + }; + + void runOutput(); + void runInput(); + + return () => { + abort.abort(); + if (pendingTimerRef.current !== null) { + clearTimeout(pendingTimerRef.current); + pendingTimerRef.current = null; + } + }; + }, [runFriendlyId, apiOrigin, orgSlug, projectSlug, envSlug]); + + return useMemo(() => { + const timestamps = timestampsRef.current; + const arr = Array.from(messagesById.values()); + arr.sort((a, b) => { + const ta = timestamps.get(a.id) ?? 0; + const tb = timestamps.get(b.id) ?? 0; + if (ta !== tb) return ta - tb; + // Tie-breaker for messages sharing a stream ID bucket (rare): fall + // back to message id string order so the output is deterministic. + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); + return arr; + }, [messagesById]); +} + +// --------------------------------------------------------------------------- +// applyOutputChunk — minimal UIMessageChunk → UIMessage accumulator. +// --------------------------------------------------------------------------- +// +// A pared-down re-implementation of AI SDK's `processUIMessageStream` (in +// `ai@6`'s `index.mjs`). The webapp is pinned to `ai@4`, which doesn't ship +// the v5+ chunk-stream helpers, so we vendor the bits we actually use. +// +// Scope vs. canonical: +// - We render only the chunk shapes that `AgentMessageView`/`renderPart` +// actually display: text, reasoning, tool-* (input-{start,delta,available} +// + output-{available,error}), source-url, source-document, file, +// step-start/finish-step, data-*, plus metadata/finish lifecycle. +// - Unknown chunk types fall through as no-ops — defensive on purpose for a +// read-only viewer. +// - We **do not parse partial JSON for streaming tool inputs.** Canonical +// uses `parsePartialJson` (which depends on a 300-line `fixJson` state +// machine to repair incomplete JSON) so users see the input growing +// character-by-character. We skip it: tool inputs stay `undefined` +// throughout streaming and snap to the final value when +// `tool-input-available` lands. Acceptable for a viewer; can be added +// later by vendoring `fixJson` if the UX warrants it. +// +// `orchestration` carries per-message active-part trackers that mirror +// canonical's `state.activeTextParts` / `state.activeReasoningParts`. They +// let `text-delta` find the right text part by id and let `finish-step` +// clear them so a new step can re-use the same id without colliding. +// +// Returns the same object reference when nothing changes so the caller can +// skip unnecessary state flushes + React re-renders. + +type AnyPart = { [key: string]: unknown; type: string }; + +function applyOutputChunk( + msg: UIMessage, + chunk: OutputChunk, + orchestration: MessageOrchestrationState +): UIMessage { + const type = chunk.type; + + // Text parts --------------------------------------------------------------- + // + // Track each streaming text part by its index in `msg.parts`. Part ids + // are only unique *within a step* — the SDK happily reuses `text-start + // id="0"` after a `finish-step` boundary — so a delta arriving for a + // reused id needs to land on the *current* part, not every prior part + // that ever shared that id. The index map gives us O(1) "which slot is + // currently streaming this id" without any id-based search. + if (type === "text-start") { + const id = chunk.id as string; + const newIndex = (msg.parts ?? []).length; // index AFTER push + orchestration.activeTextPartIndexes.set(id, newIndex); + return withNewPart(msg, { + type: "text", + id, + text: "", + state: "streaming", + }); + } + if (type === "text-delta") { + const id = chunk.id as string; + const index = orchestration.activeTextPartIndexes.get(id); + if (index === undefined) return msg; // delta with no start — drop. + return updatePartAt(msg, index, (p) => ({ + ...p, + text: ((p as { text?: string }).text ?? "") + String(chunk.delta ?? ""), + })); + } + if (type === "text-end") { + const id = chunk.id as string; + const index = orchestration.activeTextPartIndexes.get(id); + if (index === undefined) return msg; + orchestration.activeTextPartIndexes.delete(id); + return updatePartAt(msg, index, (p) => ({ ...p, state: "done" })); + } + + // Reasoning parts ---------------------------------------------------------- + if (type === "reasoning-start") { + const id = chunk.id as string; + const newIndex = (msg.parts ?? []).length; + orchestration.activeReasoningPartIndexes.set(id, newIndex); + return withNewPart(msg, { + type: "reasoning", + id, + text: "", + state: "streaming", + }); + } + if (type === "reasoning-delta") { + const id = chunk.id as string; + const index = orchestration.activeReasoningPartIndexes.get(id); + if (index === undefined) return msg; + return updatePartAt(msg, index, (p) => ({ + ...p, + text: ((p as { text?: string }).text ?? "") + String(chunk.delta ?? ""), + })); + } + if (type === "reasoning-end") { + const id = chunk.id as string; + const index = orchestration.activeReasoningPartIndexes.get(id); + if (index === undefined) return msg; + orchestration.activeReasoningPartIndexes.delete(id); + return updatePartAt(msg, index, (p) => ({ ...p, state: "done" })); + } + + // Tool call parts ---------------------------------------------------------- + if (type === "tool-input-start") { + const toolName = String(chunk.toolName ?? ""); + return withNewPart(msg, { + type: `tool-${toolName}`, + toolCallId: chunk.toolCallId, + toolName, + state: "input-streaming", + input: undefined, + }); + } + if (type === "tool-input-delta") { + // We don't parse partial JSON, so streaming tool input deltas are a + // no-op. The full input snaps in when `tool-input-available` arrives. + return msg; + } + if (type === "tool-input-available") { + const toolName = String(chunk.toolName ?? ""); + const existingIdx = indexOfPart( + msg, + (p) => (p as { toolCallId?: string }).toolCallId === chunk.toolCallId + ); + if (existingIdx >= 0) { + return updatePartAt(msg, existingIdx, (p) => ({ + ...p, + state: "input-available", + input: chunk.input, + })); + } + // Tool input arrived without a preceding tool-input-start (some + // providers do this for fast tools) — synthesize a new part. + return withNewPart(msg, { + type: `tool-${toolName}`, + toolCallId: chunk.toolCallId, + toolName, + state: "input-available", + input: chunk.input, + }); + } + if (type === "tool-output-available") { + return updatePart(msg, (p) => + (p as { toolCallId?: string }).toolCallId === chunk.toolCallId + ? { + ...p, + state: "output-available", + output: chunk.output, + ...(chunk.preliminary === true ? { preliminary: true } : {}), + } + : null + ); + } + if (type === "tool-output-error") { + return updatePart(msg, (p) => + (p as { toolCallId?: string }).toolCallId === chunk.toolCallId + ? { ...p, state: "output-error", errorText: chunk.errorText } + : null + ); + } + + // Source / file / step / data parts — pass through as a whole ------------- + if (type === "source-url" || type === "source-document" || type === "file") { + return withNewPart(msg, chunk as unknown as AnyPart); + } + if (type === "start-step") { + return withNewPart(msg, { type: "step-start" }); + } + if (type === "finish-step") { + // Step boundary — canonical clears the active part trackers so a new + // step can re-use the same text/reasoning part IDs cleanly. The + // message itself doesn't structurally change; the previous step's + // parts stay frozen at their indexes in `msg.parts`. + orchestration.activeTextPartIndexes.clear(); + orchestration.activeReasoningPartIndexes.clear(); + return msg; + } + if (type.startsWith("data-")) { + return withNewPart(msg, chunk as unknown as AnyPart); + } + + // Metadata / lifecycle ----------------------------------------------------- + if (type === "finish" || type === "message-metadata") { + if (chunk.messageMetadata == null) return msg; + return { + ...msg, + metadata: { + ...((msg as { metadata?: Record }).metadata ?? {}), + ...(chunk.messageMetadata as Record), + }, + } as UIMessage; + } + + // Abort / error / unknown — no structural change. (`start` is handled at + // the orchestration level in the output reader, not here.) + return msg; +} + +// --- Small immutable helpers for UIMessage.parts mutation ------------------- + +function withNewPart(msg: UIMessage, part: AnyPart): UIMessage { + return { + ...msg, + parts: [...((msg.parts ?? []) as AnyPart[]), part], + } as UIMessage; +} + +function updatePart( + msg: UIMessage, + updater: (part: AnyPart) => AnyPart | null +): UIMessage { + const parts = (msg.parts ?? []) as AnyPart[]; + let changed = false; + const next = parts.map((p) => { + const updated = updater(p); + if (updated === null) return p; + changed = true; + return updated; + }); + return changed ? ({ ...msg, parts: next } as UIMessage) : msg; +} + +function indexOfPart(msg: UIMessage, predicate: (part: AnyPart) => boolean): number { + const parts = (msg.parts ?? []) as AnyPart[]; + for (let i = 0; i < parts.length; i++) { + if (predicate(parts[i]!)) return i; + } + return -1; +} + +function updatePartAt( + msg: UIMessage, + index: number, + updater: (part: AnyPart) => AnyPart +): UIMessage { + const parts = (msg.parts ?? []) as AnyPart[]; + if (index < 0 || index >= parts.length) return msg; + const next = parts.slice(); + next[index] = updater(parts[index]!); + return { ...msg, parts: next } as UIMessage; +} diff --git a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx index 297234b8d05..3cfbf7521eb 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx @@ -5,24 +5,14 @@ import { ClipboardDocumentIcon, CodeBracketSquareIcon, } from "@heroicons/react/20/solid"; -import { lazy, Suspense, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { CodeBlock } from "~/components/code/CodeBlock"; +import { StreamdownRenderer } from "~/components/code/StreamdownRenderer"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Header3 } from "~/components/primitives/Headers"; import tablerSpritePath from "~/components/primitives/tabler-sprite.svg"; import type { DisplayItem, ToolUse } from "./types"; -// Lazy load streamdown to avoid SSR issues -const StreamdownRenderer = lazy(() => - import("streamdown").then((mod) => ({ - default: ({ children }: { children: string }) => ( - - {children} - - ), - })) -); - export type PromptLink = { slug: string; version?: string; @@ -257,30 +247,59 @@ function ToolUseSection({ tools }: { tools: ToolUse[] }) { ); } -type ToolTab = "input" | "output" | "details"; +type ToolTab = "input" | "output" | "details" | "agent"; -function ToolUseRow({ tool }: { tool: ToolUse }) { +export function ToolUseRow({ tool }: { tool: ToolUse }) { const hasInput = tool.inputJson !== "{}"; const hasResult = !!tool.resultOutput; const hasDetails = !!tool.description || !!tool.parametersJson; + const hasSubAgent = !!tool.subAgent; const availableTabs: ToolTab[] = [ + ...(hasSubAgent ? (["agent"] as const) : []), ...(hasInput ? (["input"] as const) : []), ...(hasResult ? (["output"] as const) : []), ...(hasDetails ? (["details"] as const) : []), ]; - const defaultTab: ToolTab | null = hasInput ? "input" : null; - const [activeTab, setActiveTab] = useState(defaultTab); + const [activeTab, setActiveTab] = useState( + hasSubAgent ? "agent" : hasInput ? "input" : null + ); + + // Auto-select input tab when input arrives after initial render (e.g. streaming tool calls) + useEffect(() => { + if (!hasSubAgent && hasInput && activeTab === null) { + setActiveTab("input"); + } + }, [hasInput, hasSubAgent]); function handleTabClick(tab: ToolTab) { setActiveTab(activeTab === tab ? null : tab); } return ( -
+
- {tool.toolName} + {hasSubAgent && ( + + + + )} + + {tool.toolName} + + {hasSubAgent && tool.subAgent?.isStreaming && ( + + + streaming + + )} {tool.resultSummary && ( {tool.resultSummary} )} @@ -288,7 +307,11 @@ function ToolUseRow({ tool }: { tool: ToolUse }) { {availableTabs.length > 0 && ( <> -
+
{availableTabs.map((tab) => (
); } + +function SubAgentContent({ parts }: { parts: any[] }) { + // Extract sub-agent run ID from injected metadata part + const runPart = parts.find( + (p: any) => p.type === "data-subagent-run" && p.data?.runId + ); + const subAgentRunId = runPart?.data?.runId as string | undefined; + + return ( +
+ {subAgentRunId && ( +
+ + View sub-agent run + +
+ )} + {parts.map((part: any, j: number) => { + const partType = part.type as string; + + // Skip the injected metadata part — already rendered above + if (partType === "data-subagent-run") return null; + + if (partType === "text" && part.text) { + return ; + } + + if (partType === "step-start") { + return ( +
+
+ step +
+
+ ); + } + + if (partType.startsWith("tool-")) { + const subToolName = partType.slice(5); + return ( + + ); + } + + if (partType === "reasoning" && part.text) { + return ( +
+
+ {part.text} +
+
+ ); + } + + return null; + })} +
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx b/apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx index 5e8bb65688f..c243a1e4d9b 100644 --- a/apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx @@ -1,6 +1,7 @@ import { CheckIcon, ClipboardDocumentIcon } from "@heroicons/react/20/solid"; -import { lazy, Suspense, useState } from "react"; +import { Suspense, useState } from "react"; import { Button } from "~/components/primitives/Buttons"; +import { StreamdownRenderer } from "~/components/code/StreamdownRenderer"; import { Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; @@ -20,16 +21,6 @@ import type { AISpanData, DisplayItem } from "./types"; import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server"; import { SpanHorizontalTimeline } from "~/components/runs/v3/SpanHorizontalTimeline"; -const StreamdownRenderer = lazy(() => - import("streamdown").then((mod) => ({ - default: ({ children }: { children: string }) => ( - - {children} - - ), - })) -); - type AITab = "overview" | "messages" | "tools" | "prompt"; export function AISpanDetails({ diff --git a/apps/webapp/app/components/runs/v3/ai/types.ts b/apps/webapp/app/components/runs/v3/ai/types.ts index bb0fd7e74b1..c59c87865d2 100644 --- a/apps/webapp/app/components/runs/v3/ai/types.ts +++ b/apps/webapp/app/components/runs/v3/ai/types.ts @@ -22,6 +22,11 @@ export type ToolUse = { resultSummary?: string; /** Full formatted result for display in a code block */ resultOutput?: string; + /** Sub-agent output — when the tool result is a UIMessage with parts */ + subAgent?: { + parts: any[]; + isStreaming: boolean; + }; }; // --------------------------------------------------------------------------- diff --git a/apps/webapp/app/hooks/useAutoScrollToBottom.ts b/apps/webapp/app/hooks/useAutoScrollToBottom.ts new file mode 100644 index 00000000000..b8e59687ed6 --- /dev/null +++ b/apps/webapp/app/hooks/useAutoScrollToBottom.ts @@ -0,0 +1,104 @@ +import { useEffect, useLayoutEffect, useRef } from "react"; + +const AT_BOTTOM_TOLERANCE_PX = 16; + +/** + * Chat-style sticky-bottom auto-scroll behavior. + * + * Behavior: + * - On mount, finds the closest scrollable ancestor of the returned ref + * (the inspector content panel, the playground messages panel, etc.). + * - Tracks whether the user is currently "at the bottom" of that scroll + * container via a passive scroll listener. Default is `true` so the very + * first render of an existing conversation lands at the bottom, and the + * "content fits without scrolling" case stays in auto-scroll mode. + * - Whenever the dependency array changes (typically the messages array), + * if the user was at the bottom, programmatically scrolls to the new + * bottom. Uses `useLayoutEffect` so the scroll happens before paint and + * there's no one-frame flicker showing new content above the viewport. + * - Scrolling away from the bottom flips the ref to `false` → auto-scroll + * pauses. Scrolling back into the bottom band (within + * `AT_BOTTOM_TOLERANCE_PX`) flips it back to `true` → auto-scroll + * resumes. + * + * The programmatic scroll fires its own scroll event, which immediately + * re-runs the stickiness check and confirms we're still at the bottom + * (distance ≈ 0 ≤ tolerance), so the ref stays `true`. No special + * "ignore programmatic scroll" flag needed. + * + * @param deps Pass the rendered list (or any dependency that should + * trigger a re-scroll). Typically `[messages]`. + * @returns A ref to attach to the component's root element. The hook + * walks up from this element's parent to locate the scroll + * container, so the root must be mounted *inside* the + * scrollable region. + * + * @example + * ```tsx + * function ChatPanel({ messages }) { + * const rootRef = useAutoScrollToBottom([messages]); + * return ( + *
+ *
+ * {messages.map((m) => )} + *
+ *
+ * ); + * } + * ``` + */ +export function useAutoScrollToBottom(deps: ReadonlyArray) { + const rootRef = useRef(null); + const containerRef = useRef(null); + // Default true so initial mount + replay land at the bottom, and the + // no-overflow case stays sticky once content starts to grow. + const stickToBottomRef = useRef(true); + + // Locate the scroll container on mount and attach a passive scroll + // listener that updates `stickToBottomRef`. + useEffect(() => { + const findScrollContainer = (start: HTMLElement | null): HTMLElement | null => { + let current: HTMLElement | null = start; + while (current) { + const style = getComputedStyle(current); + const overflowY = style.overflowY; + if (overflowY === "auto" || overflowY === "scroll") return current; + current = current.parentElement; + } + return null; + }; + + const container = findScrollContainer(rootRef.current?.parentElement ?? null); + if (!container) return; + containerRef.current = container; + + const updateStickiness = () => { + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + stickToBottomRef.current = distanceFromBottom <= AT_BOTTOM_TOLERANCE_PX; + }; + + // Seed from current position so the first messages-effect uses an + // accurate value rather than the default `true` if the user happened + // to mount the view already scrolled. + updateStickiness(); + + container.addEventListener("scroll", updateStickiness, { passive: true }); + return () => { + container.removeEventListener("scroll", updateStickiness); + containerRef.current = null; + }; + }, []); + + // After each commit that changes the deps (typically the messages + // array), if we were at the bottom, scroll to the new bottom. + useLayoutEffect(() => { + if (!stickToBottomRef.current) return; + const container = containerRef.current; + if (!container) return; + container.scrollTop = container.scrollHeight; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return rootRef; +} diff --git a/apps/webapp/app/presenters/RunFilters.server.ts b/apps/webapp/app/presenters/RunFilters.server.ts index ff9f53429eb..44bb4c01f50 100644 --- a/apps/webapp/app/presenters/RunFilters.server.ts +++ b/apps/webapp/app/presenters/RunFilters.server.ts @@ -36,6 +36,7 @@ export async function getRunFiltersFromRequest(request: Request): Promise), + conversationSparklines: Promise.resolve({} as Record), + costSparklines: Promise.resolve({} as Record), + tokenSparklines: Promise.resolve({} as Record), + }; + } + + const agents = await this._replica.backgroundWorkerTask.findMany({ + where: { + workerId: currentWorker.id, + triggerSource: "AGENT", + }, + select: { + id: true, + slug: true, + filePath: true, + triggerSource: true, + config: true, + createdAt: true, + }, + orderBy: { + slug: "asc", + }, + }); + + const slugs = agents.map((a) => a.slug); + + if (slugs.length === 0) { + return { + agents, + activeStates: Promise.resolve({} as Record), + conversationSparklines: Promise.resolve({} as Record), + costSparklines: Promise.resolve({} as Record), + tokenSparklines: Promise.resolve({} as Record), + }; + } + + // All queries are deferred for streaming + const activeStates = this.#getActiveStates(environmentId, slugs); + const conversationSparklines = this.#getConversationSparklines(environmentId, slugs); + const costSparklines = this.#getCostSparklines(environmentId, slugs); + const tokenSparklines = this.#getTokenSparklines(environmentId, slugs); + + return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines }; + } + + /** Count runs currently executing vs suspended per agent */ + async #getActiveStates( + environmentId: string, + slugs: string[] + ): Promise> { + const queryFn = this.clickhouse.reader.query({ + name: "agentActiveStates", + query: `SELECT + task_identifier, + countIf(status = 'EXECUTING') AS running, + countIf(status IN ('WAITING_TO_RESUME', 'QUEUED_EXECUTING')) AS suspended + FROM trigger_dev.task_runs_v2 + WHERE environment_id = {environmentId: String} + AND task_identifier IN {slugs: Array(String)} + AND task_kind = 'AGENT' + AND status IN ('EXECUTING', 'WAITING_TO_RESUME', 'QUEUED_EXECUTING') + GROUP BY task_identifier`, + params: z.object({ + environmentId: z.string(), + slugs: z.array(z.string()), + }), + schema: z.object({ + task_identifier: z.string(), + running: z.coerce.number(), + suspended: z.coerce.number(), + }), + }); + + const [error, rows] = await queryFn({ environmentId, slugs }); + if (error) { + console.error("Agent active states query failed:", error); + return {}; + } + + const result: Record = {}; + for (const row of rows) { + result[row.task_identifier] = { running: row.running, suspended: row.suspended }; + } + return result; + } + + /** 24h hourly sparkline of conversation (run) count per agent */ + async #getConversationSparklines( + environmentId: string, + slugs: string[] + ): Promise> { + const queryFn = this.clickhouse.reader.query({ + name: "agentConversationSparklines", + query: `SELECT + task_identifier, + toStartOfHour(created_at) AS bucket, + count() AS val + FROM trigger_dev.task_runs_v2 + WHERE environment_id = {environmentId: String} + AND task_identifier IN {slugs: Array(String)} + AND task_kind = 'AGENT' + AND created_at >= now() - INTERVAL 24 HOUR + GROUP BY task_identifier, bucket + ORDER BY task_identifier, bucket`, + params: z.object({ + environmentId: z.string(), + slugs: z.array(z.string()), + }), + schema: z.object({ + task_identifier: z.string(), + bucket: z.string(), + val: z.coerce.number(), + }), + }); + + return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs); + } + + /** 24h hourly sparkline of LLM cost per agent */ + async #getCostSparklines( + environmentId: string, + slugs: string[] + ): Promise> { + const queryFn = this.clickhouse.reader.query({ + name: "agentCostSparklines", + query: `SELECT + task_identifier, + toStartOfHour(start_time) AS bucket, + sum(total_cost) AS val + FROM trigger_dev.llm_metrics_v1 + WHERE environment_id = {environmentId: String} + AND task_identifier IN {slugs: Array(String)} + AND start_time >= now() - INTERVAL 24 HOUR + GROUP BY task_identifier, bucket + ORDER BY task_identifier, bucket`, + params: z.object({ + environmentId: z.string(), + slugs: z.array(z.string()), + }), + schema: z.object({ + task_identifier: z.string(), + bucket: z.string(), + val: z.coerce.number(), + }), + }); + + return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs); + } + + /** 24h hourly sparkline of total tokens per agent */ + async #getTokenSparklines( + environmentId: string, + slugs: string[] + ): Promise> { + const queryFn = this.clickhouse.reader.query({ + name: "agentTokenSparklines", + query: `SELECT + task_identifier, + toStartOfHour(start_time) AS bucket, + sum(total_tokens) AS val + FROM trigger_dev.llm_metrics_v1 + WHERE environment_id = {environmentId: String} + AND task_identifier IN {slugs: Array(String)} + AND start_time >= now() - INTERVAL 24 HOUR + GROUP BY task_identifier, bucket + ORDER BY task_identifier, bucket`, + params: z.object({ + environmentId: z.string(), + slugs: z.array(z.string()), + }), + schema: z.object({ + task_identifier: z.string(), + bucket: z.string(), + val: z.coerce.number(), + }), + }); + + return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs); + } + + /** Convert ClickHouse query result to sparkline map with zero-filled 24 hourly buckets */ + #buildSparklineMap( + queryResult: [Error, null] | [null, { task_identifier: string; bucket: string; val: number }[]], + slugs: string[] + ): Record { + const [error, rows] = queryResult; + if (error) { + console.error("Agent sparkline query failed:", error); + return {}; + } + return this.#buildSparklineFromRows(rows, slugs); + } + + #buildSparklineFromRows( + rows: { task_identifier: string; bucket: string; val: number }[], + slugs: string[] + ): Record { + const now = new Date(); + const startHour = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + now.getUTCHours() - 23, + 0, + 0, + 0 + ) + ); + + const bucketKeys: string[] = []; + for (let i = 0; i < 24; i++) { + const h = new Date(startHour.getTime() + i * 3600_000); + bucketKeys.push(h.toISOString().slice(0, 13).replace("T", " ") + ":00:00"); + } + + const rowMap = new Map(); + for (const row of rows) { + rowMap.set(`${row.task_identifier}|${row.bucket}`, row.val); + } + + const result: Record = {}; + for (const slug of slugs) { + result[slug] = bucketKeys.map((key) => rowMap.get(`${slug}|${key}`) ?? 0); + } + return result; + } +} + +export const agentListPresenter = singleton("agentListPresenter", setupAgentListPresenter); + +function setupAgentListPresenter() { + return new AgentListPresenter(clickhouseClient, $replica); +} diff --git a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts index 254ec18d1c0..2002f5425ca 100644 --- a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts @@ -304,6 +304,7 @@ export class ApiRunListPresenter extends BasePresenter { durationMs: run.usageDurationMs, depth: run.depth, metadata, + taskKind: run.taskKind, ...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus( ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status, apiVersion) ), diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index f22c7ccf340..03c62e9fc23 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -1,5 +1,6 @@ import { type ClickHouse } from "@internal/clickhouse"; import { MachinePresetName } from "@trigger.dev/core/v3"; +import { RunAnnotations } from "@trigger.dev/core/v3/schemas"; import { type PrismaClient, type PrismaClientOrTransaction, @@ -34,6 +35,7 @@ export type RunListOptions = { queues?: string[]; machines?: MachinePresetName[]; errorId?: string; + sources?: string[]; //pagination direction?: Direction; cursor?: string; @@ -72,6 +74,7 @@ export class NextRunListPresenter { queues, machines, errorId, + sources, from, to, direction = "forward", @@ -89,6 +92,7 @@ export class NextRunListPresenter { const hasStatusFilters = statuses && statuses.length > 0; const hasFilters = + (sources !== undefined && sources.length > 0) || (tasks !== undefined && tasks.length > 0) || (versions !== undefined && versions.length > 0) || hasStatusFilters || @@ -186,6 +190,7 @@ export class NextRunListPresenter { queues, machines, errorId, + taskKinds: sources, page: { size: pageSize, cursor, @@ -250,6 +255,7 @@ export class NextRunListPresenter { name: run.queue.replace("task/", ""), type: run.queue.startsWith("task/") ? "task" : "custom", }, + taskKind: RunAnnotations.safeParse(run.annotations).data?.taskKind ?? "STANDARD", }; }), pagination: { diff --git a/apps/webapp/app/presenters/v3/PlaygroundPresenter.server.ts b/apps/webapp/app/presenters/v3/PlaygroundPresenter.server.ts new file mode 100644 index 00000000000..656bc425cdf --- /dev/null +++ b/apps/webapp/app/presenters/v3/PlaygroundPresenter.server.ts @@ -0,0 +1,147 @@ +import type { RuntimeEnvironmentType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database"; +import { $replica } from "~/db.server"; +import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; +import { isFinalRunStatus } from "~/v3/taskStatus"; + +export type PlaygroundAgent = { + slug: string; + filePath: string; + triggerSource: TaskTriggerSource; + config: unknown; + payloadSchema: unknown; +}; + +export type PlaygroundConversation = { + id: string; + chatId: string; + title: string; + agentSlug: string; + runFriendlyId: string | null; + runStatus: TaskRunStatus | null; + clientData: unknown; + messages: unknown; + lastEventId: string | null; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +}; + +export class PlaygroundPresenter { + async listAgents({ + environmentId, + environmentType, + }: { + environmentId: string; + environmentType: RuntimeEnvironmentType; + }): Promise { + const currentWorker = await findCurrentWorkerFromEnvironment( + { id: environmentId, type: environmentType }, + $replica + ); + + if (!currentWorker) return []; + + return $replica.backgroundWorkerTask.findMany({ + where: { + workerId: currentWorker.id, + triggerSource: "AGENT", + }, + select: { + slug: true, + filePath: true, + triggerSource: true, + config: true, + payloadSchema: true, + }, + orderBy: { slug: "asc" }, + }); + } + + async getAgent({ + environmentId, + environmentType, + agentSlug, + }: { + environmentId: string; + environmentType: RuntimeEnvironmentType; + agentSlug: string; + }): Promise { + const currentWorker = await findCurrentWorkerFromEnvironment( + { id: environmentId, type: environmentType }, + $replica + ); + + if (!currentWorker) return null; + + return $replica.backgroundWorkerTask.findFirst({ + where: { + workerId: currentWorker.id, + triggerSource: "AGENT", + slug: agentSlug, + }, + select: { + slug: true, + filePath: true, + triggerSource: true, + config: true, + payloadSchema: true, + }, + }); + } + + async getRecentConversations({ + environmentId, + agentSlug, + userId, + limit = 10, + }: { + environmentId: string; + agentSlug: string; + userId: string; + limit?: number; + }): Promise { + const conversations = await $replica.playgroundConversation.findMany({ + where: { + runtimeEnvironmentId: environmentId, + agentSlug, + userId, + }, + select: { + id: true, + chatId: true, + title: true, + agentSlug: true, + clientData: true, + messages: true, + lastEventId: true, + createdAt: true, + updatedAt: true, + run: { + select: { + friendlyId: true, + status: true, + }, + }, + }, + orderBy: { updatedAt: "desc" }, + take: limit, + }); + + return conversations.map((c) => ({ + id: c.id, + chatId: c.chatId, + title: c.title, + agentSlug: c.agentSlug, + runFriendlyId: c.run?.friendlyId ?? null, + runStatus: c.run?.status ?? null, + clientData: c.clientData, + messages: c.messages, + lastEventId: c.lastEventId, + isActive: c.run?.status ? !isFinalRunStatus(c.run.status) : false, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + })); + } +} + +export const playgroundPresenter = new PlaygroundPresenter(); diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 0ea9b37ab7f..08d979e2324 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -1,12 +1,29 @@ import { type MachinePreset, + parsePacket, prettyPrintPacket, + RunAnnotations, SemanticInternalAttributes, type TaskRunContext, TaskRunError, TriggerTraceContext, type V3TaskRunContext, } from "@trigger.dev/core/v3"; + +/** + * Minimal structural type for the user messages we extract from an agent + * run's task payload. We deliberately avoid importing AI SDK's `UIMessage` + * here because the webapp's pinned `ai@4` declares a wider role union + * (`'data' | ...`) than `@ai-sdk/react@3`'s `UIMessage` accepts. The data + * crosses a JSON boundary anyway (typedjson) — keeping this loose lets the + * client-side type be the source of truth. + */ +type AgentInitialMessage = { + id: string; + role: "user" | "assistant" | "system"; + parts?: unknown[]; + [key: string]: unknown; +}; import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic"; import { extractIdempotencyKeyScope, @@ -242,6 +259,30 @@ export class SpanPresenter extends BasePresenter { const externalTraceId = this.#getExternalTraceId(run.traceContext); + const taskKind = RunAnnotations.safeParse(run.annotations).data?.taskKind; + const isAgentRun = taskKind === "AGENT"; + + // For agent runs, extract the initial user messages that were supplied + // via the task payload (from the original `triggerTask({ payload: { messages: [...] } })` + // call). When the run was started with `trigger: "preload"`, this array + // will be empty — in that case the first user message arrives later via + // the chat-messages input stream and is picked up by the AgentView. + let agentInitialMessages: AgentInitialMessage[] = []; + if (isAgentRun && run.payload && run.payloadType !== "application/store") { + try { + const parsed = await parsePacket({ + data: typeof run.payload === "string" ? run.payload : JSON.stringify(run.payload), + dataType: run.payloadType ?? "application/json", + }); + if (parsed && typeof parsed === "object" && Array.isArray((parsed as any).messages)) { + agentInitialMessages = (parsed as any).messages as AgentInitialMessage[]; + } + } catch { + // Fall back to an empty initial message list — the AgentView will + // render whatever arrives over the input/output streams. + } + } + let region: { name: string; location: string | null } | null = null; if (run.runtimeEnvironment.type !== "DEVELOPMENT" && run.engine !== "V1") { @@ -299,6 +340,8 @@ export class SpanPresenter extends BasePresenter { isFinished, isRunning: RUNNING_STATUSES.includes(run.status), isError: isFailedRunStatus(run.status), + isAgentRun, + agentInitialMessages, payload, payloadType: run.payloadType, output, @@ -457,6 +500,7 @@ export class SpanPresenter extends BasePresenter { payloadType: true, metadata: true, metadataType: true, + annotations: true, maxAttempts: true, project: { include: { diff --git a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts index f1635f23375..fc29f5510e8 100644 --- a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts @@ -61,6 +61,7 @@ export class TaskListPresenter { const tasks = await this._replica.backgroundWorkerTask.findMany({ where: { workerId: currentWorker.id, + triggerSource: { not: "AGENT" }, }, select: { id: true, diff --git a/apps/webapp/app/presenters/v3/TestPresenter.server.ts b/apps/webapp/app/presenters/v3/TestPresenter.server.ts index af5bb93a7e7..b817bbf155e 100644 --- a/apps/webapp/app/presenters/v3/TestPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestPresenter.server.ts @@ -19,15 +19,13 @@ export class TestPresenter extends BasePresenter { const tasks = await this.#getTasks(environmentId, isDev); return { - tasks: tasks.map((task) => { - return { - id: task.id, - taskIdentifier: task.slug, - filePath: task.filePath, - friendlyId: task.friendlyId, - triggerSource: task.triggerSource, - }; - }), + tasks: tasks.map((task) => ({ + id: task.id, + taskIdentifier: task.slug, + filePath: task.filePath, + friendlyId: task.friendlyId, + triggerSource: task.triggerSource, + })), }; } @@ -54,10 +52,13 @@ export class TestPresenter extends BasePresenter { SELECT bwt.id, version, slug, "filePath", bwt."friendlyId", bwt."triggerSource" FROM latest_workers JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id + WHERE bwt."triggerSource" != 'AGENT' ORDER BY slug ASC;`; } else { const currentDeployment = await findCurrentWorkerDeployment({ environmentId: envId }); - return currentDeployment?.worker?.tasks ?? []; + return (currentDeployment?.worker?.tasks ?? []).filter( + (t) => t.triggerSource !== "AGENT" + ); } } } diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index 09abb22639e..d5360cd004a 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -373,6 +373,10 @@ export class TestTaskPresenter { ), }; } + case "AGENT": { + // AGENT tasks are filtered out by TestPresenter and shouldn't reach here + return { foundTask: false }; + } default: { return task.triggerSource satisfies never; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents/route.tsx new file mode 100644 index 00000000000..deedafd9879 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents/route.tsx @@ -0,0 +1,360 @@ +import { BeakerIcon, CpuChipIcon, MagnifyingGlassIcon } from "@heroicons/react/20/solid"; +import { type MetaFunction } from "@remix-run/node"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { Suspense } from "react"; +import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Badge } from "~/components/primitives/Badge"; +import { Header2 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableCellMenu, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { PopoverMenuItem } from "~/components/primitives/Popover"; +import { TaskFileName } from "~/components/runs/v3/TaskPath"; +import { useFuzzyFilter } from "~/hooks/useFuzzyFilter"; +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 AgentListItem, + type AgentActiveState, + agentListPresenter, +} from "~/presenters/v3/AgentListPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema, v3RunsPath, v3PlaygroundAgentPath } from "~/utils/pathBuilder"; +import { cn } from "~/utils/cn"; + +export const meta: MetaFunction = () => { + return [{ title: "Agents | Trigger.dev" }]; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + 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 result = await agentListPresenter.call({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + environmentType: environment.type, + }); + + return typeddefer(result); +}; + +export default function AgentsPage() { + const { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines } = + useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const { filterText, setFilterText, filteredItems } = useFuzzyFilter({ + items: agents, + keys: ["slug", "filePath"], + }); + + if (agents.length === 0) { + return ( + + + + + + +
+ + No agents deployed + + Create a chat agent using chat.agent() from{" "} + @trigger.dev/sdk/ai and deploy it to see it here. + +
+
+
+
+ ); + } + + return ( + + + + + +
+
+
+
+ setFilterText(e.target.value)} + autoFocus + /> +
+ + + + ID + Type + File + Active + Conversations (24h) + Cost (24h) + Tokens (24h) + Go to page + + + + {filteredItems.length > 0 ? ( + filteredItems.map((agent) => { + const path = v3RunsPath(organization, project, environment, { + tasks: [agent.slug], + }); + const agentType = + (agent.config as { type?: string } | null)?.type ?? "unknown"; + + return ( + + +
+ + } + content="Agent" + /> + {agent.slug} +
+
+ + {formatAgentType(agentType)} + + + + + + }> + –}> + {(data) => { + const state = data[agent.slug]; + if (!state || (state.running === 0 && state.suspended === 0)) { + return ( + + ); + } + return ( + + {state.running > 0 && ( + + + {state.running} + + )} + {state.running > 0 && state.suspended > 0 && ( + · + )} + {state.suspended > 0 && ( + + + {state.suspended} + + )} + + ); + }} + + + + + }> + –}> + {(data) => ( + + )} + + + + + }> + –}> + {(data) => ( + + )} + + + + + }> + –}> + {(data) => ( + + )} + + + + + + + + } + hiddenButtons={ + + Playground + + } + /> +
+ ); + }) + ) : ( + + + No agents match your filters + + + )} +
+
+
+
+
+
+
+ ); +} + +function formatAgentType(type: string): string { + switch (type) { + case "ai-sdk-chat": + return "AI SDK Chat"; + default: + return type; + } +} + +function formatCount(total: number): string { + if (total === 0) return "0"; + if (total >= 1000) return `${(total / 1000).toFixed(1)}k`; + return total.toString(); +} + +function formatCost(total: number): string { + if (total === 0) return "$0"; + if (total < 0.01) return `$${total.toFixed(4)}`; + if (total < 1) return `$${total.toFixed(2)}`; + return `$${total.toFixed(2)}`; +} + +function formatTokens(total: number): string { + if (total === 0) return "0"; + if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`; + if (total >= 1000) return `${(total / 1000).toFixed(1)}k`; + return total.toString(); +} + +function SparklinePlaceholder() { + return
; +} + +function SparklineWithTotal({ + data, + formatTotal, + color = "text-text-bright", + barColor = "#3B82F6", +}: { + data?: number[]; + formatTotal: (total: number) => string; + color?: string; + barColor?: string; +}) { + if (!data || data.every((v) => v === 0)) { + return ; + } + + const total = data.reduce((sum, v) => sum + v, 0); + const max = Math.max(...data); + + return ( +
+
+ {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..aa6e30be5f8 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx @@ -0,0 +1,1208 @@ +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 type { TriggerChatTaskParams, TriggerChatTaskResult } 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 trigger via Remix action (acts like a Next.js server action) + const triggerTask = useCallback( + async (params: TriggerChatTaskParams): Promise => { + const formData = new FormData(); + formData.set("intent", "trigger"); + formData.set("agentSlug", agent.slug); + formData.set("chatId", chatId); + formData.set("payload", JSON.stringify(params.payload)); + 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.runId || !data.publicAccessToken) { + throw new Error(data.error ?? "Failed to trigger agent"); + } + + if (data.conversationId) { + setConversationId(data.conversationId); + } + + return { runId: data.runId, publicAccessToken: data.publicAccessToken }; + }, + [actionPath, agent.slug, chatId, tags, machine, maxAttempts, maxDuration, version, region] + ); + + // Token renewal via Remix action + const renewToken = useCallback( + async ({ runId }: { chatId: string; runId: string }): Promise => { + const formData = new FormData(); + formData.set("intent", "renew"); + formData.set("agentSlug", agent.slug); + formData.set("runId", runId); + + const response = await fetch(actionPath, { method: "POST", body: formData }); + const data = (await response.json()) as { publicAccessToken?: string }; + return data.publicAccessToken; + }, + [actionPath, agent.slug] + ); + + // 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`; + + // 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, + triggerTask, + renewRunAccessToken: renewToken, + baseURL: playgroundBaseURL, + clientData: JSON.parse(clientDataJson || "{}") as Record, + ...(activeConversation?.runFriendlyId && activeConversation?.publicAccessToken + ? { + sessions: { + [activeConversation.chatId]: { + runId: activeConversation.runFriendlyId, + publicAccessToken: activeConversation.publicAccessToken, + lastEventId: activeConversation.lastEventId ?? undefined, + }, + }, + } + : {}), + }); + } + const transport = transportRef.current; + + // Keep callbacks up to date + useEffect(() => { + transport.setTriggerTask(triggerTask); + }, [triggerTask, transport]); + + useEffect(() => { + transport.setRenewRunAccessToken(renewToken); + }, [renewToken, 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, { + idleTimeoutInSeconds: 60, + metadata: safeParseJson(clientDataJsonRef.current), + }); + 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 */} +
+
+ + {formatAgentType(agent.type)} +
+
+ {session?.runId && ( + + View run + + )} + {messages.length > 0 && ( + + Copy raw + + )} + + {conversationId && ( + +
+
+ + {/* 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?.runId && ( + + )} + + )} +
+
+ ) : ( +
+ {messages.map((msg) => ( + + ))} + {isSubmitted && ( +
+
+ + Thinking... +
+
+ )} +
+ )} +
+ + {/* Error */} + {error && ( +
+ {error.message} +
+ )} + + {/* Input */} +
+
+