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..09abb22639e 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") { + let inference: ReturnType | undefined; + for (const run of latestRuns) { + try { + const parsed = await parsePacket({ data: run.payload, dataType: run.payloadType }); + inference = inferSchema(parsed, inference); + } catch { + // Skip malformed runs — inference is best-effort + } + } + if (inference) { + inferredPayloadSchema = inference.toJSONSchema(); + } + } + 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..3d9302356cc --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx @@ -0,0 +1,377 @@ +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 isLoadingRef = useRef(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() || isLoadingRef.current) return; + + isLoadingRef.current = true; + 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 { + isLoadingRef.current = false; + setIsLoading(false); + } + }, + [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 ( +
+
+
+
+