From 772119e14a3ac6e03ac9c5b3e48c496f3d126899 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 6 Mar 2026 13:35:04 +0000 Subject: [PATCH 1/4] feat: add sidebar tabs (Options, AI, Schema) to Test page Add a tabbed sidebar to the Test page for standard tasks, reusing the ClientTabs pattern from the Query page. - Options tab: existing sidebar content (machine, version, queue, etc.) - AI tab: AI-powered payload generation with streaming, supports JSON Schema, inferred schema from recent runs, and task source code lookup via tool calling for tasks without schemas - Schema tab: displays payload JSON Schema (from schemaTask), inferred schema (from recent runs via @jsonhero/schema-infer), or empty state with schemaTask docs and example code Data layer changes: - Surface payloadSchema and inferredPayloadSchema from TestTaskPresenter - Add payloadSchema and fileId to WorkerDeploymentWithWorkerTasks type - Decompress zlib-deflated source files for AI context New resource route for AI payload generation with SSE streaming, task source code tool calling, and prompt engineering to prevent hallucinated payloads. --- .server-changes/test-page-sidebar-tabs.md | 6 + .../presenters/v3/TestTaskPresenter.server.ts | 22 + .../AIPayloadTabContent.tsx | 373 ++++++++++++ .../SchemaTabContent.tsx | 93 +++ .../TestSidebarTabs.tsx | 78 +++ .../route.tsx | 538 ++++++++++-------- ...env.$envParam.test.ai-generate-payload.tsx | 309 ++++++++++ .../app/v3/models/workerDeployment.server.ts | 2 + apps/webapp/package.json | 1 + pnpm-lock.yaml | 60 ++ 10 files changed, 1229 insertions(+), 253 deletions(-) create mode 100644 .server-changes/test-page-sidebar-tabs.md create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/TestSidebarTabs.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.ai-generate-payload.tsx diff --git a/.server-changes/test-page-sidebar-tabs.md b/.server-changes/test-page-sidebar-tabs.md new file mode 100644 index 00000000000..1803f2c2ac0 --- /dev/null +++ b/.server-changes/test-page-sidebar-tabs.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add sidebar tabs (Options, AI, Schema) to the Test page for schemaTask payload generation and schema viewing. diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index 2817b7c8b8f..017402f496d 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -6,6 +6,7 @@ import { type TaskRunTemplate, PrismaClientOrTransaction, } from "@trigger.dev/database"; +import { inferSchema } from "@jsonhero/schema-infer"; import parse from "parse-duration"; import { type PrismaClient } from "~/db.server"; import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; @@ -34,6 +35,8 @@ type Task = { taskIdentifier: string; filePath: string; friendlyId: string; + payloadSchema?: unknown; + inferredPayloadSchema?: unknown; }; type Queue = { @@ -244,11 +247,30 @@ export class TestTaskPresenter { }, }); + // Infer schema from existing run payloads when no explicit schema is defined + let inferredPayloadSchema: unknown | undefined; + if (!task.payloadSchema && latestRuns.length > 0 && task.triggerSource === "STANDARD") { + try { + let inference: ReturnType | undefined; + for (const run of latestRuns) { + const parsed = await parsePacket({ data: run.payload, dataType: run.payloadType }); + inference = inferSchema(parsed, inference); + } + if (inference) { + inferredPayloadSchema = inference.toJSONSchema(); + } + } catch { + // Ignore inference errors — it's best-effort + } + } + const taskWithEnvironment = { id: task.id, taskIdentifier: task.slug, filePath: task.filePath, friendlyId: task.friendlyId, + payloadSchema: task.payloadSchema ?? undefined, + inferredPayloadSchema, }; switch (task.triggerSource) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx new file mode 100644 index 00000000000..cc240e8fd3a --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx @@ -0,0 +1,373 @@ +import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { AnimatePresence, motion } from "framer-motion"; +import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"; +import { SparkleListIcon } from "~/assets/icons/SparkleListIcon"; +import { Button } from "~/components/primitives/Buttons"; +import { Header3 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { cn } from "~/utils/cn"; + +const StreamdownRenderer = lazy(() => + import("streamdown").then((mod) => ({ + default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => ( + + {children} + + ), + })) +); + +type StreamEventType = + | { type: "thinking"; content: string } + | { type: "result"; success: true; payload: string } + | { type: "result"; success: false; error: string }; + +export function AIPayloadTabContent({ + onPayloadGenerated, + payloadSchema, + taskIdentifier, + getCurrentPayload, +}: { + onPayloadGenerated: (payload: string) => void; + payloadSchema?: unknown; + taskIdentifier: string; + getCurrentPayload?: () => string; +}) { + const [prompt, setPrompt] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [thinking, setThinking] = useState(""); + const [error, setError] = useState(null); + const [showThinking, setShowThinking] = useState(false); + const [lastResult, setLastResult] = useState<"success" | "error" | null>(null); + const textareaRef = useRef(null); + const abortControllerRef = useRef(null); + + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/test/ai-generate-payload`; + + const submitGeneration = useCallback( + async (queryPrompt: string) => { + if (!queryPrompt.trim() || isLoading) return; + + setIsLoading(true); + setThinking(""); + setError(null); + setShowThinking(true); + setLastResult(null); + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + + try { + const formData = new FormData(); + formData.append("prompt", queryPrompt); + formData.append("taskIdentifier", taskIdentifier); + if (payloadSchema) { + formData.append("payloadSchema", JSON.stringify(payloadSchema)); + } + const currentPayload = getCurrentPayload?.(); + if (currentPayload) { + formData.append("currentPayload", currentPayload); + } + + const response = await fetch(resourcePath, { + method: "POST", + body: formData, + signal: abortControllerRef.current.signal, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { error?: string }; + setError(errorData.error || "Failed to generate payload"); + setIsLoading(false); + setLastResult("error"); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + setError("No response stream"); + setIsLoading(false); + setLastResult("error"); + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const event = JSON.parse(line.slice(6)) as StreamEventType; + processStreamEvent(event); + } catch { + // Ignore parse errors + } + } + } + } + + if (buffer.startsWith("data: ")) { + try { + const event = JSON.parse(buffer.slice(6)) as StreamEventType; + processStreamEvent(event); + } catch { + // Ignore parse errors + } + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return; + setError(err instanceof Error ? err.message : "An error occurred"); + setLastResult("error"); + } finally { + setIsLoading(false); + } + }, + [isLoading, resourcePath, taskIdentifier, payloadSchema, getCurrentPayload] + ); + + const processStreamEvent = useCallback( + (event: StreamEventType) => { + switch (event.type) { + case "thinking": + setThinking((prev) => prev + event.content); + break; + case "result": + if (event.success) { + onPayloadGenerated(event.payload); + setPrompt(""); + setLastResult("success"); + } else { + setError(event.error); + setLastResult("error"); + } + break; + } + }, + [onPayloadGenerated] + ); + + const handleSubmit = useCallback( + (e?: React.FormEvent) => { + e?.preventDefault(); + submitGeneration(prompt); + }, + [prompt, submitGeneration] + ); + + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(null), 15000); + return () => clearTimeout(timer); + } + }, [error]); + + const examplePrompts = payloadSchema + ? [ + "Generate a valid payload", + "Generate a payload with edge cases", + "Generate a minimal payload with only required fields", + ] + : [ + "Generate a simple JSON payload", + "Generate a payload with nested objects", + "Generate a payload with an array of items", + ]; + + return ( +
+
+
+
+