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/.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/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/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index d64fc96488c..50c9ede6a53 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, @@ -470,6 +473,22 @@ export function SideMenu({ )} onCollapseToggle={handleSectionToggle("ai")} > + + ; @@ -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/ai/AIChatMessages.tsx b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx index 297234b8d05..b1983587fef 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx @@ -259,7 +259,7 @@ function ToolUseSection({ tools }: { tools: ToolUse[] }) { type ToolTab = "input" | "output" | "details"; -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; 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/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/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..923fa2bb6e2 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx @@ -0,0 +1,1190 @@ +import { + ArrowUpIcon, + BoltIcon, + CpuChipIcon, + StopIcon, + ArrowPathIcon, + TrashIcon, +} from "@heroicons/react/20/solid"; +import { type MetaFunction } from "@remix-run/node"; +import { Link, useFetcher, useNavigate } 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 { Header3 } from "~/components/primitives/Headers"; +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 { + 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 { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { env as serverEnv } from "~/env.server"; +import { generateJWT as internal_generateJWT } 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 { activeConversation } = useTypedLoaderData(); + // Key on conversation chatId so React remounts all stateful children when + // navigating between conversations (Link changes search params, loader re-runs, + // but without a key change the component instance is reused and useState + // initializers / useRef initializations don't re-run). + const conversationKey = activeConversation?.chatId ?? "new"; + return ; +} + +function PlaygroundChat() { + const { agent, apiOrigin, recentConversations, activeConversation } = + useTypedLoaderData(); + 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 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.trim()) formData.set("tags", tags.trim()); + if (machine) formData.set("machine", machine); + + 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] + ); + + // 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"; + + // 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 */} +
+
+ + {agent.slug} + {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 */} +
+
+