Skip to content

feat: AI prompt management dashboard and enhanced span inspectors#3244

Draft
ericallam wants to merge 9 commits intomainfrom
feature/tri-7888-ai-prompts
Draft

feat: AI prompt management dashboard and enhanced span inspectors#3244
ericallam wants to merge 9 commits intomainfrom
feature/tri-7888-ai-prompts

Conversation

@ericallam
Copy link
Member

@ericallam ericallam commented Mar 21, 2026

  • Full prompt management UI: list, detail, override, and version management for AI prompts defined with prompts.define()
  • Rich AI span inspectors for all AI SDK operations with token usage, messages, and prompt context
  • Real-time generation tracking with live polling and filtering

Prompt management

Define prompts in your code with prompts.define(), then manage versions and overrides from the dashboard without redeploying:

import { task, prompts } from "@trigger.dev/sdk";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const supportPrompt = prompts.define({
  id: "customer-support",
  model: "gpt-4o",
  variables: z.object({
    customerName: z.string(),
    plan: z.string(),
    issue: z.string(),
  }),
  content: `You are a support agent for Acme SaaS.
Customer: {{customerName}} ({{plan}} plan)
Issue: {{issue}}
Respond with empathy and precision.`,
});

export const supportTask = task({
  id: "handle-support",
  run: async (payload) => {
    const resolved = await supportPrompt.resolve({
      customerName: payload.name,
      plan: payload.plan,
      issue: payload.issue,
    });

    const result = await generateText({
      model: openai(resolved.model ?? "gpt-4o"),
      system: resolved.text,
      prompt: payload.issue,
      ...resolved.toAISDKTelemetry(),
    });

    return { response: result.text };
  },
});

The prompts list page shows each prompt with its current version, model, override status, and a usage sparkline over the last 24 hours.

From the prompt detail page you can:

  • Create overrides to change the prompt template or model without redeploying. Overrides take priority over the deployed version when prompt.resolve() is called.
  • Promote any code-deployed version to be the current version
  • Browse generations across all versions with infinite scroll and live polling for new results
  • Filter by version, model, operation type, and provider
  • View metrics (total generations, avg tokens, avg cost, latency) broken down by version

AI span inspectors

Every AI SDK operation now gets a custom inspector in the run trace view:

  • ai.generateText / ai.streamText — Shows model, token usage, cost, the full message thread (system prompt, user message, assistant response), and linked prompt details
  • ai.generateObject / ai.streamObject — Same as above plus the JSON schema and structured output
  • ai.toolCall — Shows tool name, call ID, and input arguments
  • ai.embed — Shows model and the text being embedded

For generation spans linked to a prompt, a "Prompt" tab shows the prompt metadata, the input variables passed to resolve(), and the template content from the prompt version.

All AI span inspectors include a compact timestamp and duration header.

Other improvements

  • Resizable panel sizes now persist across page refreshes (patched @window-splitter/state to fix snapshot restoration)
  • Run page panels also persist their sizes
  • Fixed <div> inside <p> DOM nesting warnings in span titles and chat messages
  • Added Operations and Providers filters to the AI metrics dashboard

Screenshots

CleanShot 2026-03-21 at 10 14 17@2x CleanShot 2026-03-21 at 10 15 37@2x CleanShot 2026-03-21 at 10 15 54@2x

@changeset-bot
Copy link

changeset-bot bot commented Mar 21, 2026

⚠️ No Changeset found

Latest commit: 7a681c2

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 21, 2026

Walkthrough

Adds end-to-end prompt management: database migrations and Prisma models for prompts and prompt_versions; ClickHouse schema and ingestion fields (prompt_slug, prompt_version); server-side presenter and service implementations (PromptPresenter, PromptService) plus multiple authenticated API routes for listing, resolving, promoting, and managing overrides; UI pages/components (prompts list, prompt detail, filters, dashboard integration, inspector views); SDK/CLI/catalog support to declare and ship prompts (definePrompt, resource-catalog, MCP tools); worker indexing, background prompt upsert logic, and event enrichment with prompt telemetry.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120+ minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title 'feat: AI prompt management dashboard and enhanced span inspectors' clearly and concisely summarizes the main changes—adding prompt management functionality and improving AI span inspectors.
Description check ✅ Passed The pull request description is comprehensive and well-organized, covering the main features (prompt management, AI span inspectors, real-time tracking), including code examples, feature explanations, and screenshots that demonstrate the changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/tri-7888-ai-prompts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal-packages/llm-pricing/src/default-model-prices.json (1)

3967-3971: ⚠️ Potential issue | 🟠 Major

Widen the live Gemini prefix too.

This is the only gemini* entry in the catalog that still rejects googleai/... — all other 16 Gemini models use the widened google(ai)?/ pattern. If callers resolve the live model with that prefix, pricing will miss. Update the pattern and re-run pnpm run sync-prices.

Suggested fix
-    "matchPattern": "(?i)^(google\/)?(gemini-live-2.5-flash-native-audio)$",
+    "matchPattern": "(?i)^(google(ai)?\/)?(gemini-live-2.5-flash-native-audio)$",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal-packages/llm-pricing/src/default-model-prices.json` around lines
3967 - 3971, Update the matchPattern for the entry with id
"029e6695-ff24-47f0-b37b-7285fb2e5785" (modelName
"gemini-live-2.5-flash-native-audio") to accept the widened live Gemini prefix
used by other Gemini entries: replace the current
"(?i)^(google\/)?(gemini-live-2.5-flash-native-audio)$" pattern with one that
allows both "google/" and "googleai/" prefixes (matching the other Gemini
entries), then re-run "pnpm run sync-prices" to propagate the change.
apps/webapp/app/components/metrics/QueryWidget.tsx (1)

452-458: ⚠️ Potential issue | 🟠 Major

Fullscreen table ignores hidden column configuration.

Line 452-458 renders TSQLResultsTable without hiddenColumns, so hidden fields reappear when maximized.

Proposed fix
                 <TSQLResultsTable
                   rows={data.rows}
                   columns={data.columns}
                   prettyFormatting={config.prettyFormatting}
                   sorting={config.sorting}
                   showHeaderOnEmpty={showTableHeaderOnEmpty}
+                  hiddenColumns={hiddenColumns}
                 />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/metrics/QueryWidget.tsx` around lines 452 - 458,
The fullscreen TSQLResultsTable render is missing the hiddenColumns prop so
columns configured as hidden reappear when maximized; update the
TSQLResultsTable usage in QueryWidget (the block rendering TSQLResultsTable) to
pass the hiddenColumns from the current config (e.g.,
hiddenColumns={config.hiddenColumns}) so the table respects hidden field
configuration in both normal and fullscreen modes.
🟡 Minor comments (10)
apps/webapp/app/components/runs/v3/ai/AIEmbedSpanDetails.tsx-74-79 (1)

74-79: ⚠️ Potential issue | 🟡 Minor

Fix minute/second formatting to avoid 60s outputs.

At Line 78, .toFixed(0) can round up and produce invalid displays like 1m 60s. Use floor/truncation for seconds.

Proposed fix
 function formatDuration(ms: number): string {
   if (ms < 1000) return `${Math.round(ms)}ms`;
   if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
   const mins = Math.floor(ms / 60_000);
-  const secs = ((ms % 60_000) / 1000).toFixed(0);
+  const secs = Math.floor((ms % 60_000) / 1000);
   return `${mins}m ${secs}s`;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/runs/v3/ai/AIEmbedSpanDetails.tsx` around lines 74
- 79, The formatDuration function can produce "1m 60s" because secs is computed
with toFixed(0) which can round up; update formatDuration to compute seconds
with truncation/flooring instead (e.g., secs = Math.floor((ms % 60_000) / 1000))
so seconds never equal 60, and return the string using that secs value; change
occurs in the formatDuration function where secs is currently set with
.toFixed(0).
internal-packages/clickhouse/src/llmMetrics.ts-41-41 (1)

41-41: ⚠️ Potential issue | 🟡 Minor

Constrain prompt_version to an integer.

prompt_version currently accepts any number; allowing decimals here can create invalid prompt-version semantics in stored metrics.

Proposed fix
-  prompt_version: z.number(),
+  prompt_version: z.number().int().nonnegative(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal-packages/clickhouse/src/llmMetrics.ts` at line 41, The
prompt_version field in the Zod schema is currently z.number(), which allows
non-integer values; update the prompt_version schema to only accept integers
(e.g., replace z.number() with z.number().int() or add a refine that checks
Number.isInteger) so stored metrics can't have decimal prompt versions; change
the prompt_version entry in the llmMetrics schema accordingly.
apps/webapp/app/components/code/TSQLResultsTable.tsx-1049-1052 (1)

1049-1052: ⚠️ Potential issue | 🟡 Minor

Handle “all columns hidden” using visibleColumns to avoid blank rendering.

After introducing hiddenColumns, table guards still use columns.length. If all columns are hidden, the component can render an unusable empty structure.

💡 Suggested fix
-  if (!columns.length) return null;
+  if (!visibleColumns.length) {
+    return <ChartBlankState icon={IconTable} message="No columns to display" />;
+  }
...
-              <td className="w-full px-3 py-6" colSpan={columns.length}>
+              <td className="w-full px-3 py-6" colSpan={visibleColumns.length}>

Also applies to: 1106-1106, 1153-1153

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/code/TSQLResultsTable.tsx` around lines 1049 -
1052, The component's rendering guards still check columns.length instead of the
computed visibleColumns, so if all columns are hidden the table renders empty;
update every guard and conditional that currently uses columns.length to use
visibleColumns.length (the useMemo result) and add an explicit early return or
fallback UI when visibleColumns.length === 0; search for occurrences around
where visibleColumns is used (e.g., in TSQLResultsTable render/guard logic and
the other two spots noted) and replace those checks so the component does not
render an empty table when all columns are hidden.
apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx-63-66 (1)

63-66: ⚠️ Potential issue | 🟡 Minor

Keep telemetry prompt fallback when structured prompt fields are missing.

Filtering out prompt unconditionally can hide prompt data for older spans that only populate telemetryMetadata.prompt.

💡 Suggested fix
-        {aiData.telemetryMetadata &&
-          Object.entries(aiData.telemetryMetadata)
-            .filter(([key]) => key !== "prompt")
-            .map(([key, value]) => <MetricRow key={key} label={key} value={value} />)}
+        {aiData.telemetryMetadata &&
+          Object.entries(aiData.telemetryMetadata)
+            .filter(([key]) => !(key === "prompt" && aiData.promptSlug))
+            .map(([key, value]) => <MetricRow key={key} label={key} value={value} />)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx` around lines 63 -
66, Currently the code unconditionally filters out the "prompt" key from
aiData.telemetryMetadata, which removes prompt fallback for older spans; modify
the logic so "prompt" is only excluded when structured prompt fields exist—e.g.,
compute hasStructuredPrompt = Object.keys(aiData.telemetryMetadata).some(k => k
!== "prompt" && k.startsWith("prompt")) (or check for known structured keys) and
then only filter out "prompt" when hasStructuredPrompt is true; keep the mapping
to MetricRow unchanged so MetricRow still receives telemetry entries including
the fallback "prompt" when no structured prompt fields are present.
apps/webapp/app/routes/api.v1.prompts.$slug.override.ts-27-31 (1)

27-31: ⚠️ Potential issue | 🟡 Minor

Keep the 405 responses aligned with the actual method set.

The loader's non-OPTIONS path drops the CORS wrapper and Allow header entirely, and the action advertises POST, PUT, DELETE even though PATCH is accepted above. Unsupported-method clients will get misleading metadata.

Also applies to: 113-116

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/routes/api.v1.prompts`.$slug.override.ts around lines 27 -
31, The loader's non-OPTIONS branch returns a bare 405 without CORS or an
accurate Allow header; update loader (export async function loader) to wrap the
405 response with apiCors and include an Allow header that matches the actual
supported methods (include PATCH alongside POST, PUT, DELETE as accepted
elsewhere), and likewise fix the action's 405 paths (the branch around lines
~113-116) so they also use apiCors and the same correct Allow header to avoid
misleading metadata.
apps/webapp/app/components/runs/v3/PromptSpanDetails.tsx-38-43 (1)

38-43: ⚠️ Potential issue | 🟡 Minor

Reset the active tab when the available sections change.

tab persists across prop changes. If someone is on "Input" or "Template" and then opens a prompt span without that section, the header renders with no active tab and the body stays blank until they click "Overview".

Suggested fix
-import { lazy, Suspense, useState } from "react";
+import { lazy, Suspense, useEffect, useState } from "react";
@@
   const [tab, setTab] = useState<PromptTab>("overview");
+
+  useEffect(() => {
+    if ((tab === "input" && !hasInput) || (tab === "template" && !hasTemplate)) {
+      setTab("overview");
+    }
+  }, [tab, hasInput, hasTemplate]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/runs/v3/PromptSpanDetails.tsx` around lines 38 -
43, The active tab stored in state (tab via useState and setTab) can become
invalid when availableTabs changes; add a useEffect in the PromptSpanDetails
component that watches availableTabs and, if the current tab is not included in
availableTabs, calls setTab(availableTabs[0]) (or another sane default) to reset
to a valid tab; ensure the effect lists availableTabs (or a stable derived key)
in its dependency array so the active tab updates whenever availableTabs
changes.
packages/cli-v3/src/mcp/tools/prompts.ts-50-61 (1)

50-61: ⚠️ Potential issue | 🟡 Minor

Add error handling for non-JSON error responses.

If the API returns a non-JSON error response (e.g., 500 with HTML), res.json() will throw before the error message can be extracted. Consider handling this case.

🛡️ Proposed fix for safer error handling
 async function fetchPromptApi(
   apiClient: { fetchClient: typeof fetch; baseUrl: string },
   path: string,
   options?: RequestInit
 ) {
   const res = await apiClient.fetchClient(`${apiClient.baseUrl}/api/v1/prompts${path}`, options);
-  const data = await res.json();
   if (!res.ok) {
-    throw new Error(data.error ?? `API error ${res.status}`);
+    let errorMessage = `API error ${res.status}`;
+    try {
+      const data = await res.json();
+      if (data.error) errorMessage = data.error;
+    } catch {
+      // Response wasn't JSON, use status code message
+    }
+    throw new Error(errorMessage);
   }
-  return data;
+  return res.json();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli-v3/src/mcp/tools/prompts.ts` around lines 50 - 61, The
fetchPromptApi function currently calls res.json() unconditionally which will
throw on non-JSON responses; update fetchPromptApi to first read the response
body safely (e.g., await res.text()), then if res.ok try to parse JSON
(JSON.parse) and return it, and if !res.ok attempt to parse the text as JSON to
extract data.error but fall back to including the raw text and status in the
thrown Error; reference the existing fetchPromptApi function and
apiClient.fetchClient call and replace the direct res.json() usage with the safe
text-then-JSON parsing/fallback logic so non-JSON error responses (HTML, plain
text) are surfaced in the error message.
apps/webapp/app/presenters/v3/SpanPresenter.server.ts-841-849 (1)

841-849: ⚠️ Potential issue | 🟡 Minor

Handle potential NaN from version parsing.

Number(promptVersion) on a non-numeric string returns NaN, which would cause the Prisma query to fail or return unexpected results. Consider adding validation.

🛡️ Proposed fix to validate version number
     const version = await this._replica.promptVersion.findUnique({
       where: {
         promptId_version: {
           promptId: prompt.id,
-          version: Number(promptVersion),
+          version: parseInt(promptVersion, 10),
         },
       },
     });
-    if (!version) return undefined;
+    if (!version || isNaN(version.version)) return undefined;

Alternatively, validate before the query:

+    const versionNum = parseInt(promptVersion, 10);
+    if (isNaN(versionNum)) return undefined;
+
     const version = await this._replica.promptVersion.findUnique({
       where: {
         promptId_version: {
           promptId: prompt.id,
-          version: Number(promptVersion),
+          version: versionNum,
         },
       },
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/presenters/v3/SpanPresenter.server.ts` around lines 841 -
849, The code calls this._replica.promptVersion.findUnique using
Number(promptVersion) which can be NaN; before querying, parse and validate
promptVersion (e.g., with Number.isFinite / Number.isInteger or parseInt) and
handle invalid values by returning undefined or throwing as appropriate; update
the logic around the version lookup (the promptVersion input, the Number(...)
conversion, and the variable version) so the query only runs with a valid
numeric version (and include prompt.id in the check) to avoid passing NaN into
Prisma.
apps/webapp/app/v3/services/promptService.server.ts-115-116 (1)

115-116: ⚠️ Potential issue | 🟡 Minor

Same race condition applies to reactivateOverride.

Consider wrapping the #removeLabel and #addLabel calls in a transaction here as well for consistency with createOverride.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/v3/services/promptService.server.ts` around lines 115 - 116,
reactivateOverride currently calls this.#removeLabel(promptId, "override") and
this.#addLabel(versionId, "override") separately, causing the same race
condition as createOverride; modify reactivateOverride to run both label
operations inside a single database transaction (use the same transaction helper
or mechanism used by createOverride) so that the remove/add occur atomically,
pass the transaction/context into `#removeLabel` and `#addLabel` (or call
transactional variants), and ensure errors roll back the transaction and
propagate appropriately.
apps/webapp/app/v3/services/promptService.server.ts-21-22 (1)

21-22: ⚠️ Potential issue | 🟡 Minor

Potential race condition between label operations.

#removeLabel and #addLabel are not wrapped in a transaction, so concurrent promoteVersion calls could leave the system in an inconsistent state (e.g., no version with "current" label, or multiple versions with it).

🔒 Suggested fix: wrap in transaction
-    await this.#removeLabel(promptId, "current");
-    await this.#addLabel(versionId, "current");
+    await this._prisma.$transaction(async (tx) => {
+      await tx.$executeRaw`
+        UPDATE "prompt_versions"
+        SET "labels" = array_remove("labels", 'current')
+        WHERE "promptId" = ${promptId} AND 'current' = ANY("labels")
+      `;
+      await tx.$executeRaw`
+        UPDATE "prompt_versions"
+        SET "labels" = array_append("labels", 'current')
+        WHERE "id" = ${versionId} AND NOT ('current' = ANY("labels"))
+      `;
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/v3/services/promptService.server.ts` around lines 21 - 22,
The two label operations (`#removeLabel` and `#addLabel`) can race; wrap them in a
single database transaction inside the promoteVersion flow so both run
atomically on the same DB session/connection: begin a transaction, call
`#removeLabel`(promptId, "current") then `#addLabel`(versionId, "current") using the
transactional context, commit on success and rollback on error, and ensure both
helper methods accept/propagate the transaction/session (or inline their SQL/ORM
calls into the transaction) so concurrent promoteVersion calls cannot leave
multiple or zero "current" labels.
🧹 Nitpick comments (16)
apps/webapp/app/components/code/TextEditor.tsx (3)

12-19: Use a type alias for TextEditorProps

Line 12 uses an interface; this repo standard prefers type aliases in TS/TSX.

♻️ Suggested change
-export interface TextEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
+export type TextEditorProps = Omit<ReactCodeMirrorProps, "onBlur"> & {
   defaultValue?: string;
   readOnly?: boolean;
   onChange?: (value: string) => void;
   onUpdate?: (update: ViewUpdate) => void;
   showCopyButton?: boolean;
   additionalActions?: React.ReactNode;
-}
+};

As per coding guidelines, **/*.{ts,tsx}: Use types over interfaces for TypeScript.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/code/TextEditor.tsx` around lines 12 - 19, Replace
the interface declaration with a type alias: change the exported TextEditorProps
interface into an exported type alias that equals Omit<ReactCodeMirrorProps,
"onBlur"> & { ... } and keep all existing fields (defaultValue, readOnly,
onChange, onUpdate, showCopyButton, additionalActions) unchanged; update any
imports/uses still referring to TextEditorProps if needed but the symbol name
stays the same (TextEditorProps) and the file TextEditor.tsx should reflect the
type alias form.

1-125: Add @crumbs tracing markers in this new TSX component

This file introduces new behavior but has no @crumbs markers. Please add either inline // @Crumbs comments or a `// `#region` `@crumbs block per repo convention.

As per coding guidelines, **/*.{ts,tsx,js}: Add crumbs as you write code using // @crumbs comments or // #region @crumbs blocks for agentcrumbs debug tracing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/code/TextEditor.tsx` around lines 1 - 125, Add the
required `@crumbs` tracing markers to this new TextEditor component by inserting
either inline // `@crumbs` comments or a // `#region` `@crumbs` block around the
component entry points: place a top-level marker just above the TextEditor
function declaration and add markers at key internal points such as inside the
useEffect that sets the container (useRef editor/current), the useEffect that
syncs defaultValue to view, and the copy callback (copy) to trace user actions
and lifecycle; ensure markers reference the function names (TextEditor, copy)
and the view/setContainer usage so they are easy to find.

33-46: Use declarative array construction instead of mutation

Lines 33-46 build extensions via sequential push() calls. While getEditorSetup(false) returns a fresh array each invocation (not shared), imperative mutation is less readable and maintainable. Create the array declaratively using the spread operator.

♻️ Suggested change
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@
-  const extensions = getEditorSetup(false);
-  extensions.push(EditorView.lineWrapping);
-  extensions.push(
-    lineNumbers({
-      formatNumber: (n) => String(n),
-    })
-  );
-  extensions.push(
-    EditorView.theme({
-      ".cm-lineNumbers": {
-        minWidth: "40px",
-      },
-    })
-  );
+  const extensions = useMemo(
+    () => [
+      ...getEditorSetup(false),
+      EditorView.lineWrapping,
+      lineNumbers({
+        formatNumber: (n) => String(n),
+      }),
+      EditorView.theme({
+        ".cm-lineNumbers": {
+          minWidth: "40px",
+        },
+      }),
+    ],
+    []
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/code/TextEditor.tsx` around lines 33 - 46, Replace
the imperative pushes that mutate the extensions array with a declarative array
construction: create a new array by spreading the result of
getEditorSetup(false) and appending EditorView.lineWrapping, the
lineNumbers(...) call (with formatNumber), and the EditorView.theme(...) object
(with ".cm-lineNumbers" minWidth). Update the variable named extensions to be
initialized with that single array expression so the code uses a readable
immutable pattern around getEditorSetup, EditorView.lineWrapping, lineNumbers,
and EditorView.theme.
packages/cli-v3/src/entryPoints/managed-index-worker.ts (1)

205-220: Consider consolidating prompt/task schema conversion into one shared helper.

The two conversion paths now duplicate the same error-handling and mapping pattern; a generic helper would reduce drift risk.

Also applies to: 222-239

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli-v3/src/entryPoints/managed-index-worker.ts` around lines 205 -
220, The convertPromptSchemasToJsonSchemas function duplicates the mapping +
try/catch pattern used elsewhere (e.g., the similar task conversion block around
lines 222-239); extract a shared helper (e.g., mapWithSchemaConversion) that
accepts the items array and a schema lookup function
(resourceCatalog.getPromptSchema / getTaskSchema) and performs the
schemaToJsonSchema conversion with the same safe try/catch logic, then replace
convertPromptSchemasToJsonSchemas and the task conversion with calls to that
helper to avoid duplicated error handling and mapping logic.
apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts._index/route.tsx (1)

98-100: Unreachable code: empty state already handled earlier.

The TableBlankRow on lines 98-100 will never render because prompts.length === 0 is already handled with an early return at line 64. This ternary can be simplified to just map the prompts directly.

♻️ Suggested simplification
           <TableBody>
-            {prompts.length === 0 ? (
-              <TableBlankRow colSpan={7}>No prompts found</TableBlankRow>
-            ) : (
-              prompts.map((prompt) => {
+            {prompts.map((prompt) => {
                 const path = `${v3PromptsPath(organization, project, environment)}/${prompt.slug}`;
                 const activeVersion = prompt.overrideVersion ?? prompt.currentVersion;
                 const isOverride = !!prompt.overrideVersion;
                 // ... rest of mapping
-              })
-            )}
+            })}
           </TableBody>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/webapp/app/routes/_app.orgs`.$organizationSlug.projects.$projectParam.env.$envParam.prompts._index/route.tsx
around lines 98 - 100, The ternary that renders TableBlankRow when
prompts.length === 0 is unreachable because an early return handles the empty
state; remove the conditional branch and simplify the JSX to directly map over
prompts (use the existing prompts array mapping logic) and delete the
unreachable TableBlankRow usage; update the render in the route component where
prompts is referenced and remove any dead code path that checks prompts.length
=== 0 (symbols to locate: prompts, TableBlankRow).
apps/webapp/app/v3/services/createBackgroundWorker.server.ts (1)

710-711: Move import to the top of the file.

The import { createHash } from "crypto" statement is placed at the end of the file (line 711), which is unconventional and may cause confusion. Node.js built-in imports should be grouped with other imports at the top.

♻️ Move import to top

Add at the top with other imports (around line 1-9):

import { createHash } from "crypto";

Then remove lines 710-711.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/v3/services/createBackgroundWorker.server.ts` around lines
710 - 711, The file currently contains a stray "import { createHash } from
'crypto'" near the end; move that import up into the existing import block at
the top of the file with the other imports (so createHash is imported alongside
the top-level imports) and remove the trailing import statement at the bottom
(the one currently on lines 710-711) to keep imports grouped and conventional.
apps/webapp/app/components/metrics/ProvidersFilter.tsx (1)

18-20: Prefer a type alias for the props shape.

This repo standardizes on type instead of interface in TypeScript files. As per coding guidelines, "Use types over interfaces for TypeScript".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/metrics/ProvidersFilter.tsx` around lines 18 - 20,
Replace the interface declaration for ProvidersFilterProps with a type alias to
follow project conventions: change the declaration "interface
ProvidersFilterProps { possibleProviders: string[] }" to a type alias named
ProvidersFilterProps with the same shape (possibleProviders: string[]), ensuring
any references to ProvidersFilterProps (e.g., in the ProvidersFilter component
props) continue to work unchanged.
apps/webapp/app/components/metrics/OperationsFilter.tsx (1)

18-20: Prefer a type alias for the props shape.

This repo standardizes on type instead of interface in TypeScript files. As per coding guidelines, "Use types over interfaces for TypeScript".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/metrics/OperationsFilter.tsx` around lines 18 -
20, Replace the interface declaration for OperationsFilterProps with a type
alias to match repository standards: change the "interface OperationsFilterProps
{ possibleOperations: string[] }" to a "type OperationsFilterProps = {
possibleOperations: string[] }" and ensure any usages of OperationsFilterProps
(e.g., in the OperationsFilter component props) continue to reference the same
symbol.
apps/webapp/app/components/metrics/PromptsFilter.tsx (1)

18-20: Prefer a type alias for the props shape.

This repo standardizes on type instead of interface in TypeScript files. As per coding guidelines, "Use types over interfaces for TypeScript".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/metrics/PromptsFilter.tsx` around lines 18 - 20,
Replace the interface declaration for PromptsFilterProps with a type alias:
change "interface PromptsFilterProps { possiblePrompts: string[] }" to "type
PromptsFilterProps = { possiblePrompts: string[] }"; update any usages or
exports of PromptsFilterProps as needed (no other logic changes).
apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx (1)

296-311: Consider extracting shared helpers to reduce duplication.

PromptMetricRow and tryPrettyJson are duplicated in AIToolCallSpanDetails.tsx. Consider extracting these to a shared module (e.g., ~/components/runs/v3/ai/shared.tsx) if this pattern continues to expand.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx` around lines 296 -
311, Extract the duplicated PromptMetricRow component and tryPrettyJson function
into a shared module (e.g., create a new file like shared.tsx) and replace the
local definitions in AISpanDetails.tsx and AIToolCallSpanDetails.tsx with
imports from that module; specifically move the PromptMetricRow function and the
tryPrettyJson function into the shared file, export them, update both files to
import { PromptMetricRow, tryPrettyJson } from the new shared module, and run a
quick typecheck to ensure props and return types align.
apps/webapp/app/routes/api.v1.prompts.$slug.ts (1)

152-174: Template compiler has limited Mustache compatibility.

The compileTemplate function handles basic {{key}} interpolation and simple {{#key}}...{{/key}} blocks, but:

  1. Nested blocks ({{#outer}}{{#inner}}...{{/inner}}{{/outer}}) won't work correctly due to greedy regex
  2. Inverted sections ({{^key}}) are not supported
  3. Comments ({{! comment }}) are not stripped

If this is intentional for a simplified template syntax, consider documenting the supported syntax. Otherwise, consider using a lightweight template library.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/routes/api.v1.prompts`.$slug.ts around lines 152 - 174, The
compileTemplate function currently uses greedy regexes which break nested
sections (e.g., {{`#outer`}}{{`#inner`}}...{{/inner}}{{/outer}}), and it does not
handle inverted sections ({{^key}}) or strip comments ({{! ... }}); either
document these limitations in the function's JSDoc and README as intentional, or
replace the implementation with a proper Mustache-compatible renderer: remove
the ad-hoc regex logic in compileTemplate and call a lightweight library (e.g.,
mustache.render or hogan.js) to get correct handling of nested blocks, inverted
sections, and comments, or implement a small recursive parser that matches
opening/closing tags to support nesting and add support for ^-sections and
comment stripping before interpolation.
internal-packages/database/prisma/schema.prisma (1)

588-588: Consider documenting expected source values.

The source field is a String with comment noting expected values. Consider adding a more detailed comment or using a Prisma @default value to make the expected values clearer for future maintainers.

source        String // Expected: "code" | "dashboard" | "api"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal-packages/database/prisma/schema.prisma` at line 588, The schema's
source field currently is a plain String (source) with an inline comment; change
it to a stronger, self-documenting type or add a clearer default/comment: either
define a Prisma enum (e.g., enum Source { deploy dashboard api }) and replace
source String with source Source, or add a more explicit inline comment and a
sensible `@default` (e.g., `@default`("dashboard")) to schema.prisma so the expected
values ("deploy" | "dashboard" | "api") are enforced/visible; update any code
that writes/reads this field to use the enum values if you choose the enum route
(look for usages of source in migrations, models, and repository functions).
packages/trigger-sdk/src/v3/prompt.ts (2)

56-67: Template conditional section regex may not handle nested conditionals.

The regex /\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g uses non-greedy matching which works for simple cases, but nested conditionals like {{#a}}{{#b}}...{{/b}}{{/a}} would be incorrectly parsed due to the first {{/ being matched with the outer {{#a}}.

If nested conditionals are not a supported feature, consider adding a note in the docstring. Otherwise, a recursive or stack-based parser would be needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trigger-sdk/src/v3/prompt.ts` around lines 56 - 67, The current
conditional parser using template.replace with the regex
/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g (assigned to result) does not support
nested conditionals (e.g., {{`#a`}}{{`#b`}}...{{/b}}{{/a}}); either explicitly
document in the prompt module's function comment that nested conditionals are
not supported, or replace the regex-based approach with a simple
recursive/stack-based parser: parse the template char-by-char, push opening tags
({{`#key`}}) onto a stack, build node/content trees, and evaluate nodes using
variables to correctly handle nested {{#...}}{{/...}} blocks—update the function
that currently performs the replace to use this parser and keep existing
variable interpolation logic for inner content.

160-208: API resolution lacks explicit error handling.

If apiClient.resolvePrompt throws or returns an error response, the promise rejects without a user-friendly message. Consider wrapping in try/catch to provide context about prompt resolution failures.

💡 Suggested improvement
       if (ctx && apiClient) {
+        try {
         const response = await apiClient.resolvePrompt(
           options.id,
           // ... existing code ...
         );
         // ... existing return logic ...
+        } catch (error) {
+          throw new Error(
+            `Failed to resolve prompt "${options.id}": ${error instanceof Error ? error.message : String(error)}`
+          );
+        }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trigger-sdk/src/v3/prompt.ts` around lines 160 - 208, Wrap the
apiClient.resolvePrompt(...) call inside a try/catch in the same block where ctx
and apiClient are checked (the prompt resolution path that currently invokes
apiClient.resolvePrompt with tracer/name "prompt.resolve()"); on catch, create a
clear, contextual error message that includes the prompt id (options.id) and any
relevant resolveOptions (label/version) and either log it via the existing
logger/tracer or throw a new Error that wraps the original error so callers get
a user-friendly message; ensure you preserve and rethrow the original error as
the cause if available so stack traces remain useful.
apps/webapp/app/presenters/v3/PromptPresenter.server.ts (2)

6-32: Consider consolidating GenerationRow definitions.

The GenerationRowSchema (zod) and GenerationRow (type) have slightly different field names (started_at vs start_time). While the transformation at line 334 handles this, having two similar definitions could lead to drift.

Consider deriving the type from the schema:

type GenerationRowRaw = z.infer<typeof GenerationRowSchema>;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/presenters/v3/PromptPresenter.server.ts` around lines 6 - 32,
The GenerationRowSchema and GenerationRow types are duplicated and have a
mismatched field name (started_at vs start_time); derive a single source of
truth by using z.infer<typeof GenerationRowSchema> (e.g., type GenerationRowRaw
= z.infer<typeof GenerationRowSchema>) and then export/rename a cleaned type for
callers (GenerationRow) that matches the rest of the code, and update the
transformation that converts started_at to start_time to return the
derived/renamed type so you no longer maintain two divergent definitions.

128-141: Timezone mismatch risk in sparkline bucket alignment.

The bucket key generation uses toISOString().slice(0, 13) which produces UTC timestamps, but ClickHouse's toStartOfHour(start_time) may use the server's timezone setting. If they don't align, counts will be attributed to wrong buckets.

Consider explicitly using UTC in the ClickHouse query:

💡 Suggested fix
       query: `SELECT
           prompt_slug,
-          toStartOfHour(start_time) AS bucket,
+          formatDateTime(toStartOfHour(start_time), '%Y-%m-%d %H:00:00', 'UTC') AS bucket,
           count() AS cnt

And update the JavaScript to match:

       bucketKeys.push(
-        h.toISOString().slice(0, 13).replace("T", " ") + ":00:00"
+        h.toISOString().slice(0, 13).replace("T", " ") + ":00:00"  // Already UTC
       );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/presenters/v3/PromptPresenter.server.ts` around lines 128 -
141, The bucket generation uses local-hour math then toISOString (UTC), which
can misalign with ClickHouse; switch to pure-UTC calculations and make
ClickHouse use UTC for hour bucketing. Concretely: in PromptPresenter.server.ts
change the startHour computation and per-hour loop to compute times in
milliseconds from Date.now() (or use getUTCHours/getUTC*), e.g. derive
startUtcMs = Date.now() - 23*3600*1000 and build each bucket with new
Date(startUtcMs + i*3600*1000) so toISOString() yields the intended UTC hour
strings; and update the ClickHouse query to call toStartOfHour(start_time,
'UTC') (or the equivalent timezone param) so both JS bucketKeys and ClickHouse
grouping use UTC consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a29af65c-2bdd-4a1f-b68d-9bc64a956682

📥 Commits

Reviewing files that changed from the base of the PR and between 35298ac and 37e3367.

⛔ Files ignored due to path filters (3)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • references/hello-world/package.json is excluded by !references/**
  • references/hello-world/src/trigger/prompts.ts is excluded by !references/**
📒 Files selected for processing (74)
  • apps/webapp/app/components/BlankStatePanels.tsx
  • apps/webapp/app/components/code/TSQLResultsTable.tsx
  • apps/webapp/app/components/code/TextEditor.tsx
  • apps/webapp/app/components/metrics/OperationsFilter.tsx
  • apps/webapp/app/components/metrics/PromptsFilter.tsx
  • apps/webapp/app/components/metrics/ProvidersFilter.tsx
  • apps/webapp/app/components/metrics/QueryWidget.tsx
  • apps/webapp/app/components/navigation/SideMenu.tsx
  • apps/webapp/app/components/primitives/Resizable.tsx
  • apps/webapp/app/components/runs/v3/PromptSpanDetails.tsx
  • apps/webapp/app/components/runs/v3/SpanTitle.tsx
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
  • apps/webapp/app/components/runs/v3/ai/AIEmbedSpanDetails.tsx
  • apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx
  • apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx
  • apps/webapp/app/components/runs/v3/ai/AIToolCallSpanDetails.tsx
  • apps/webapp/app/components/runs/v3/ai/extractAISpanData.ts
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/components/runs/v3/ai/index.ts
  • apps/webapp/app/components/runs/v3/ai/types.ts
  • apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts
  • apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
  • apps/webapp/app/presenters/v3/SpanPresenter.server.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts._index/route.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
  • apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.ts
  • apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts
  • apps/webapp/app/routes/api.v1.prompts._index.ts
  • apps/webapp/app/routes/resources.metric.tsx
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug.generations.ts
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx
  • apps/webapp/app/services/authorization.server.ts
  • apps/webapp/app/services/queryService.server.ts
  • apps/webapp/app/utils/pathBuilder.ts
  • apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts
  • apps/webapp/app/v3/eventRepository/eventRepository.types.ts
  • apps/webapp/app/v3/querySchemas.ts
  • apps/webapp/app/v3/services/createBackgroundWorker.server.ts
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/v3/utils/enrichCreatableEvents.server.ts
  • internal-packages/clickhouse/schema/025_add_prompt_columns_to_llm_metrics_v1.sql
  • internal-packages/clickhouse/src/llmMetrics.ts
  • internal-packages/database/prisma/migrations/20260317221618_add_prompt_management/migration.sql
  • internal-packages/database/prisma/migrations/20260318114244_add_prompt_friendly_id/migration.sql
  • internal-packages/database/prisma/schema.prisma
  • internal-packages/llm-pricing/src/default-model-prices.json
  • internal-packages/llm-pricing/src/defaultPrices.ts
  • package.json
  • packages/cli-v3/src/dev/devSupervisor.ts
  • packages/cli-v3/src/entryPoints/dev-index-worker.ts
  • packages/cli-v3/src/entryPoints/managed-index-worker.ts
  • packages/cli-v3/src/mcp/config.ts
  • packages/cli-v3/src/mcp/tools.ts
  • packages/cli-v3/src/mcp/tools/prompts.ts
  • packages/core/src/v3/apiClient/index.ts
  • packages/core/src/v3/resource-catalog/catalog.ts
  • packages/core/src/v3/resource-catalog/index.ts
  • packages/core/src/v3/resource-catalog/noopResourceCatalog.ts
  • packages/core/src/v3/resource-catalog/standardResourceCatalog.ts
  • packages/core/src/v3/schemas/api.ts
  • packages/core/src/v3/schemas/build.ts
  • packages/core/src/v3/schemas/resources.ts
  • packages/core/src/v3/schemas/schemas.ts
  • packages/core/src/v3/types/tasks.ts
  • packages/trigger-sdk/src/v3/index.ts
  • packages/trigger-sdk/src/v3/prompt.ts
  • packages/trigger-sdk/src/v3/prompts.ts
  • patches/@window-splitter__state@0.4.1.patch

Comment on lines +81 to +86
const copy = useCallback(() => {
if (view === undefined) return;
navigator.clipboard.writeText(view.state.doc.toString());
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [view]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does navigator.clipboard.writeText() return a Promise that can reject, and in which cases (permissions/insecure context/user gesture)?

💡 Result:

Yes, navigator.clipboard.writeText returns a Promise that can reject with a NotAllowedError DOMException. It rejects in these cases: - Insecure context (non-HTTPS, non-localhost): navigator.clipboard is undefined, so calling writeText throws a TypeError (property undefined). - Secure context but permission denied: e.g., clipboard-write permission not granted (Chromium), or lacking transient user activation (user gesture like click; required by most browsers including Safari/Firefox strictly, Chromium with conditions). - Not in active tab or lacking document focus (browser-specific, e.g., Chromium requires active tab). - Permissions Policy disallows clipboard-write (e.g., in iframes without proper header). Always wrap in try-catch or .catch as shown in MDN examples.

Citations:


🏁 Script executed:

# Check if the file exists and examine the exact code at lines 81-86
if [ -f "apps/webapp/app/components/code/TextEditor.tsx" ]; then
  echo "File found. Showing lines 78-90 for context:"
  sed -n '78,90p' "apps/webapp/app/components/code/TextEditor.tsx"
else
  echo "File not found. Searching for TextEditor.tsx..."
  find . -name "TextEditor.tsx" -type f 2>/dev/null | head -5
fi

Repository: triggerdotdev/trigger.dev

Length of output: 434


Handle clipboard write failures before showing success

The navigator.clipboard.writeText() call on line 83 returns a Promise that can reject due to permission denial, insecure context, lacking user activation, or permissions policy violations. Without error handling, a rejection surfaces as an unhandled promise rejection and the "copied" UI state shows incorrectly.

Suggested change
-  const copy = useCallback(() => {
+  const copy = useCallback(async () => {
     if (view === undefined) return;
-    navigator.clipboard.writeText(view.state.doc.toString());
-    setCopied(true);
-    setTimeout(() => setCopied(false), 1500);
+    try {
+      await navigator.clipboard.writeText(view.state.doc.toString());
+      setCopied(true);
+      setTimeout(() => setCopied(false), 1500);
+    } catch {
+      setCopied(false);
+    }
   }, [view]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const copy = useCallback(() => {
if (view === undefined) return;
navigator.clipboard.writeText(view.state.doc.toString());
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [view]);
const copy = useCallback(async () => {
if (view === undefined) return;
try {
await navigator.clipboard.writeText(view.state.doc.toString());
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
setCopied(false);
}
}, [view]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/code/TextEditor.tsx` around lines 81 - 86, The
copy function in TextEditor currently calls
navigator.clipboard.writeText(view.state.doc.toString()) without awaiting or
handling failures, causing unhandled promise rejections and incorrect setCopied
UI state; update the copy callback (the useCallback named copy) to await
navigator.clipboard.writeText(...) or chain .then/.catch, only call
setCopied(true) on success, call setCopied(false) or leave unchanged on failure,
and log or surface the error (e.g., via console.error or an existing logger) so
permission/context failures are handled gracefully.

Comment on lines +85 to +110
function OperationsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleOperations,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleOperations: string[];
}) {
const { values, replace } = useSearchParams();

const handleChange = (values: string[]) => {
clearSearchValue();
replace({ operations: values });
};

const filtered = useMemo(() => {
const q = searchValue.toLowerCase();
return possibleOperations.filter(
(op) => op.toLowerCase().includes(q) || formatOperation(op).toLowerCase().includes(q)
);
}, [searchValue, possibleOperations]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wire the combobox back to the search state.

filtered only reacts to searchValue, but OperationsDropdown only receives clearSearchValue and ComboBox only gets the current value. Typing here won't update the rendered list, so the operation search is effectively broken.

Also applies to: 113-125

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/metrics/OperationsFilter.tsx` around lines 85 -
110, The filtering is only tied to searchValue while the ComboBox input isn't
updating that state; update OperationsDropdown so the ComboBox input drives the
search used by filtered: either propagate ComboBox input changes into the search
param (useSearchParams values/replace) or maintain a local inputState in
OperationsDropdown and include it in the useMemo dependency list; ensure
ComboBox receives the current input value and an onInputChange that updates that
state (so filtered recalculates), and keep handleChange to update the selected
operations via replace({ operations: values }) and clearSearchValue as before;
adjust the logic around filtered, ComboBox props, and any references to
searchValue in OperationsDropdown/handleChange accordingly.

Comment on lines +74 to +98
function PromptsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possiblePrompts,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possiblePrompts: string[];
}) {
const { values, replace } = useSearchParams();

const handleChange = (values: string[]) => {
clearSearchValue();
replace({ prompts: values });
};

const filtered = useMemo(() => {
return possiblePrompts.filter((p) => {
return p.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possiblePrompts]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wire the combobox back to the search state.

filtered only reacts to searchValue, but PromptsDropdown only receives clearSearchValue and ComboBox only gets the current value. Typing here won't update the rendered list, so the prompt search is effectively broken.

Also applies to: 101-113

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/metrics/PromptsFilter.tsx` around lines 74 - 98,
PromptsDropdown's filtered list only depends on the local searchValue, but the
component reads/writes the persistent search state via useSearchParams (values,
replace) and the ComboBox is controlled by that state, so typing doesn't update
the rendered list; fix by reading the current prompts/search term from values
(values.prompts or a dedicated values.search key) and include that in the
filtered useMemo dependencies, ensure handleChange calls replace with the
updated prompts array/string, and make the ComboBox controlled by the same
search param (pass the parsed values.prompts as its value and wire its onChange
to handleChange/replace while still calling clearSearchValue where appropriate)
so filtered, ComboBox value, and URL/search params stay in sync (adjust parsing
if values.prompts is a string vs array).

Comment on lines +74 to +96
function ProvidersDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleProviders,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleProviders: string[];
}) {
const { values, replace } = useSearchParams();

const handleChange = (values: string[]) => {
clearSearchValue();
replace({ providers: values });
};

const filtered = useMemo(() => {
return possibleProviders.filter((p) => p.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue, possibleProviders]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wire the combobox back to the search state.

filtered only reacts to searchValue, but ProvidersDropdown only receives clearSearchValue and ComboBox only gets the current value. Typing here won't update the rendered list, so the provider search is effectively broken.

Also applies to: 99-111

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/metrics/ProvidersFilter.tsx` around lines 74 - 96,
ProvidersDropdown’s filtered list is only using the searchValue prop so typing
in the ComboBox doesn’t update the rendered list; change filtered to derive its
input from the search state returned by useSearchParams (the values object)
instead of — or in addition to — the searchValue prop, and wire the ComboBox
input handlers to update that search param via replace (and call
clearSearchValue where appropriate). Specifically, update the useMemo that
computes filtered to depend on values (or the exact search param key you use,
e.g. values.providerSearch or values.providers) and possibleProviders, and
ensure the ComboBox receives the controlled input from values and calls a
handler that calls replace(...) so typing updates values and recomputes
filtered; keep handleChange for committing provider selections.

Comment on lines +460 to +489
<SideMenuSection
title="AI"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
user.dashboardPreferences.sideMenu,
"ai"
)}
onCollapseToggle={handleSectionToggle("ai")}
>
<SideMenuItem
name="Prompts"
icon={DocumentTextIcon}
activeIconColor="text-purple-500"
inactiveIconColor="text-purple-500"
to={v3PromptsPath(organization, project, environment)}
data-action="prompts"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="AI Metrics"
icon={SparklesIcon}
activeIconColor="text-purple-500"
inactiveIconColor="text-purple-500"
to={v3BuiltInDashboardPath(organization, project, environment, "llm")}
data-action="ai-metrics"
isCollapsed={isCollapsed}
/>
</SideMenuSection>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Gate “AI Metrics” behind the same query-access check used for dashboards.

Right now AI Metrics is always rendered, which can expose a nav path users may not be authorized to access.

Proposed fix
               <SideMenuItem
                 name="Prompts"
                 icon={DocumentTextIcon}
                 activeIconColor="text-purple-500"
                 inactiveIconColor="text-purple-500"
                 to={v3PromptsPath(organization, project, environment)}
                 data-action="prompts"
                 isCollapsed={isCollapsed}
               />
-              <SideMenuItem
-                name="AI Metrics"
-                icon={SparklesIcon}
-                activeIconColor="text-purple-500"
-                inactiveIconColor="text-purple-500"
-                to={v3BuiltInDashboardPath(organization, project, environment, "llm")}
-                data-action="ai-metrics"
-                isCollapsed={isCollapsed}
-              />
+              {(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
+                <SideMenuItem
+                  name="AI Metrics"
+                  icon={SparklesIcon}
+                  activeIconColor="text-purple-500"
+                  inactiveIconColor="text-purple-500"
+                  to={v3BuiltInDashboardPath(organization, project, environment, "llm")}
+                  data-action="ai-metrics"
+                  isCollapsed={isCollapsed}
+                />
+              )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/navigation/SideMenu.tsx` around lines 460 - 489,
The "AI Metrics" SideMenuItem is always rendered and should be conditionally
shown using the same query-access check used for dashboards; wrap the
SideMenuItem that links to v3BuiltInDashboardPath(...) in that existing
permission check (the same helper used when rendering the dashboards nav), so
only render the SideMenuItem when the check passes—leave the "Prompts" item and
the surrounding SideMenuSection, getSectionCollapsed and handleSectionToggle
logic unchanged.

Comment on lines +52 to +65
const url = new URL(request.url);
const versions = url.searchParams.getAll("versions").filter(Boolean).map(Number).filter((n) => !isNaN(n));
const period = url.searchParams.get("period") ?? "7d";
const fromTime = url.searchParams.get("from");
const toTime = url.searchParams.get("to");
const cursorParam = url.searchParams.get("cursor") ?? undefined;

const periodMs = parsePeriodToMs(period);
const startTime = fromTime ? new Date(fromTime) : new Date(Date.now() - periodMs);
const endTime = toTime ? new Date(toTime) : new Date();

const models = url.searchParams.getAll("models").filter(Boolean);
const operations = url.searchParams.getAll("operations").filter(Boolean);
const providers = url.searchParams.getAll("providers").filter(Boolean);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the file exists and get its content
cat -n apps/webapp/app/routes/resources.orgs.\$organizationSlug.projects.\$projectParam.env.\$envParam.prompts.\$promptSlug.generations.ts | head -100

Repository: triggerdotdev/trigger.dev

Length of output: 3777


🏁 Script executed:

# Check how listGenerations uses these parameters
fd -t f "PromptPresenter" --exec grep -l "listGenerations" {} \;

Repository: triggerdotdev/trigger.dev

Length of output: 126


🏁 Script executed:

# Check the PromptPresenter implementation
cat -n ./apps/webapp/app/presenters/v3/PromptPresenter.server.ts | head -150

Repository: triggerdotdev/trigger.dev

Length of output: 5574


🏁 Script executed:

# Continue reading to find listGenerations method
cat -n ./apps/webapp/app/presenters/v3/PromptPresenter.server.ts | sed -n '150,350p'

Repository: triggerdotdev/trigger.dev

Length of output: 7809


🏁 Script executed:

# Verify Invalid Date behavior by checking if toISOString is called
grep -A 5 "toISOString" ./apps/webapp/app/presenters/v3/PromptPresenter.server.ts | head -10

Repository: triggerdotdev/trigger.dev

Length of output: 376


🏁 Script executed:

# Check if there are any zod schemas used in the loader for query validation
grep -B5 -A10 "loader = async" ./apps/webapp/app/routes/resources.orgs.\$organizationSlug.projects.\$projectParam.env.\$envParam.prompts.\$promptSlug.generations.ts

Repository: triggerdotdev/trigger.dev

Length of output: 715


🏁 Script executed:

# Verify what happens when Invalid Date is used - check if there are any safeguards
grep -rn "Invalid Date\|toISOString\|RangeError" ./apps/webapp/app/routes/resources.orgs.\$organizationSlug.projects.\$projectParam.env.\$envParam.prompts.\$promptSlug.generations.ts

Repository: triggerdotdev/trigger.dev

Length of output: 51


🏁 Script executed:

# Double-check the date parsing and usage one more time to be thorough
sed -n '52,80p' ./apps/webapp/app/routes/resources.orgs.\$organizationSlug.projects.\$projectParam.env.\$envParam.prompts.\$promptSlug.generations.ts

Repository: triggerdotdev/trigger.dev

Length of output: 1362


Use zod to validate query parameters before constructing the time window.

Query strings for from, to, versions, period, models, operations, and providers are parsed without validation. new Date(fromTime) and new Date(toTime) can produce Invalid Date objects, which will throw a RangeError when .toISOString() is called at line 300 in the presenter. Additionally, Number() accepts decimals and Infinity, not just valid prompt version integers. These invalid inputs should fail fast with a 400 response, not propagate into listGenerations(). Per coding guidelines, "Use zod for validation in packages/core and apps/webapp".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/webapp/app/routes/resources.orgs`.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug.generations.ts
around lines 52 - 65, The query parameters (from/to, versions, period, models,
operations, providers) are parsed without validation which can yield Invalid
Date or bad numbers; add a zod schema at the top of the request handler in
generations.ts to validate and coerce these query params (validate period with
parsePeriodToMs-compatible values, parse versions as integers >0, ensure from/to
are valid ISO datetimes, and models/operations/providers are arrays of non-empty
strings), return a 400 response on schema failure, and only then construct
startTime/endTime (using new Date(...) on validated values) and call
listGenerations; reference parsePeriodToMs, the URL search params (versions,
period, from/to, models, operations, providers), and listGenerations when wiring
the validated values through.

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "public"."prompts" ADD COLUMN "friendly_id" TEXT NOT NULL;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's read the complete migration file
cat internal-packages/database/prisma/migrations/20260318114244_add_prompt_friendly_id/migration.sql

Repository: triggerdotdev/trigger.dev

Length of output: 253


🏁 Script executed:

# Check if there are any related migrations that might create indexes on this column
fd "migration.sql" internal-packages/database/prisma/migrations | xargs grep -l "friendly_id" 2>/dev/null

Repository: triggerdotdev/trigger.dev

Length of output: 262


🏁 Script executed:

# Let's also check if there's any context about the prompts table structure
fd "migration.sql" internal-packages/database/prisma/migrations | xargs grep -l "CREATE TABLE.*prompts" 2>/dev/null | head -5

Repository: triggerdotdev/trigger.dev

Length of output: 164


Separate this migration into two files: (1) add friendly_id column with staged rollout, (2) create unique index in separate migration with CONCURRENTLY.

Adding NOT NULL directly to an existing table without a staged migration (nullable column → backfill → add constraint) will fail on environments with existing data. Additionally, the unique index must be created in a separate migration file using CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS to avoid table locks in production.

Current migration structure (problematic)
ALTER TABLE "public"."prompts" ADD COLUMN "friendly_id" TEXT NOT NULL;
CREATE UNIQUE INDEX "prompts_friendly_id_key" ON "public"."prompts"("friendly_id");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@internal-packages/database/prisma/migrations/20260318114244_add_prompt_friendly_id/migration.sql`
at line 2, Split the single migration into two migrations: (1) change the ALTER
TABLE "prompts" ADD COLUMN "friendly_id" TEXT NOT NULL into a migration that
adds the column as nullable (ALTER TABLE ... ADD COLUMN "friendly_id" TEXT), run
a backfill step to populate friendly_id for existing rows, and then in a
follow-up migration add the NOT NULL constraint (ALTER TABLE ... ALTER COLUMN
"friendly_id" SET NOT NULL); (2) move the unique index creation into a separate
migration and create it using a non-blocking statement such as CREATE UNIQUE
INDEX CONCURRENTLY IF NOT EXISTS "prompts_friendly_id_key" ON
"public"."prompts"("friendly_id") so the index is built without locking the
table. Ensure the migrations reference the column name friendly_id, the table
prompts, and the index name prompts_friendly_id_key so they apply cleanly in
order.

ALTER TABLE "public"."prompts" ADD COLUMN "friendly_id" TEXT NOT NULL;

-- CreateIndex
CREATE UNIQUE INDEX "prompts_friendly_id_key" ON "public"."prompts"("friendly_id");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify prompts-table migration history and current index statement usage
fd migration.sql internal-packages/database/prisma/migrations -x rg -n \
'CREATE TABLE "public"\."prompts"|ALTER TABLE "public"\."prompts"|prompts_friendly_id_key|CREATE UNIQUE INDEX'

Repository: triggerdotdev/trigger.dev

Length of output: 23196


🏁 Script executed:

cat -n internal-packages/database/prisma/migrations/20260318114244_add_prompt_friendly_id/migration.sql

Repository: triggerdotdev/trigger.dev

Length of output: 288


Split the index creation into a separate migration file using CONCURRENTLY.

Line 5 creates a unique index on an existing table in the same migration as the column addition. Per coding guidelines, this should be split into a separate migration file with CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "prompts_friendly_id_key" ON "public"."prompts"("friendly_id"); to avoid table locks in production.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@internal-packages/database/prisma/migrations/20260318114244_add_prompt_friendly_id/migration.sql`
at line 5, Move the unique index creation for "prompts_friendly_id_key" out of
this migration and into a new, separate migration that runs after the column
addition; in that new migration use CREATE UNIQUE INDEX CONCURRENTLY IF NOT
EXISTS "prompts_friendly_id_key" ON "public"."prompts"("friendly_id") so the
index is created without taking an exclusive lock on the existing "prompts"
table (reference the index name prompts_friendly_id_key, table "prompts" and
column "friendly_id" when creating the new migration).

Comment on lines +3787 to +3898
{
"id": "4bf01a9f-663f-4302-a05c-b2b42c5348e3",
"modelName": "gpt-5.4-mini",
"matchPattern": "(?i)^(openai\\/)?(gpt-5.4-mini)$",
"createdAt": "2026-03-18T00:00:00.000Z",
"updatedAt": "2026-03-18T00:00:00.000Z",
"tokenizerConfig": {
"tokensPerName": 1,
"tokenizerModel": "gpt-4",
"tokensPerMessage": 3
},
"tokenizerId": "openai",
"pricingTiers": [
{
"id": "4bf01a9f-663f-4302-a05c-b2b42c5348e3_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {
"input": 0.75e-6,
"input_cached_tokens": 0.075e-6,
"input_cache_read": 0.075e-6,
"output": 4.5e-6
}
}
]
},
{
"id": "3d8027df-7491-442e-9770-1363ab2452fc",
"modelName": "gpt-5.4-mini-2026-03-17",
"matchPattern": "(?i)^(openai\\/)?(gpt-5.4-mini-2026-03-17)$",
"createdAt": "2026-03-18T00:00:00.000Z",
"updatedAt": "2026-03-18T00:00:00.000Z",
"tokenizerConfig": {
"tokensPerName": 1,
"tokenizerModel": "gpt-4",
"tokensPerMessage": 3
},
"tokenizerId": "openai",
"pricingTiers": [
{
"id": "3d8027df-7491-442e-9770-1363ab2452fc_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {
"input": 0.75e-6,
"input_cached_tokens": 0.075e-6,
"input_cache_read": 0.075e-6,
"output": 4.5e-6
}
}
]
},
{
"id": "2d83d130-d0f1-4b5d-be1c-7caf70b8e444",
"modelName": "gpt-5.4-nano",
"matchPattern": "(?i)^(openai\\/)?(gpt-5.4-nano)$",
"createdAt": "2026-03-18T00:00:00.000Z",
"updatedAt": "2026-03-18T00:00:00.000Z",
"tokenizerConfig": {
"tokensPerName": 1,
"tokenizerModel": "gpt-4",
"tokensPerMessage": 3
},
"tokenizerId": "openai",
"pricingTiers": [
{
"id": "2d83d130-d0f1-4b5d-be1c-7caf70b8e444_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {
"input": 0.2e-6,
"input_cached_tokens": 0.02e-6,
"input_cache_read": 0.02e-6,
"output": 1.25e-6
}
}
]
},
{
"id": "33c7321b-c56e-45e9-9640-33ba3f5cb4fa",
"modelName": "gpt-5.4-nano-2026-03-17",
"matchPattern": "(?i)^(openai\\/)?(gpt-5.4-nano-2026-03-17)$",
"createdAt": "2026-03-18T00:00:00.000Z",
"updatedAt": "2026-03-18T00:00:00.000Z",
"tokenizerConfig": {
"tokensPerName": 1,
"tokenizerModel": "gpt-4",
"tokensPerMessage": 3
},
"tokenizerId": "openai",
"pricingTiers": [
{
"id": "33c7321b-c56e-45e9-9640-33ba3f5cb4fa_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {
"input": 0.2e-6,
"input_cached_tokens": 0.02e-6,
"input_cache_read": 0.02e-6,
"output": 1.25e-6
}
}
]
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add the missing reasoning price aliases for the new GPT-5.4 mini/nano entries.

Every other gpt-5* record in this table maps output_reasoning_tokens and output_reasoning to the same rate as output, but these four omit both keys. If usage for GPT-5.4 mini/nano is emitted under reasoning-specific details, those tokens will currently price at zero. Re-run pnpm run sync-prices after fixing the source data so the generated TypeScript stays aligned.

Representative fix — apply the same addition to all four new entries
         "prices": {
           "input": 0.75e-6,
           "input_cached_tokens": 0.075e-6,
           "input_cache_read": 0.075e-6,
-          "output": 4.5e-6
+          "output": 4.5e-6,
+          "output_reasoning_tokens": 4.5e-6,
+          "output_reasoning": 4.5e-6
         }

Run the following to confirm which gpt-5* entries are missing the reasoning aliases:

#!/bin/bash
set -euo pipefail

python - <<'PY'
import json
from pathlib import Path

path = Path("internal-packages/llm-pricing/src/default-model-prices.json")
models = json.loads(path.read_text())

for model in models:
    name = model["modelName"]
    if not name.startswith("gpt-5"):
        continue

    prices = model["pricingTiers"][0]["prices"]
    missing = [key for key in ("output_reasoning_tokens", "output_reasoning") if key not in prices]
    if missing:
        print(f"{name}: missing {', '.join(missing)}")
PY
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal-packages/llm-pricing/src/default-model-prices.json` around lines
3787 - 3898, The four new GPT-5.4 entries (modelName "gpt-5.4-mini",
"gpt-5.4-mini-2026-03-17", "gpt-5.4-nano", "gpt-5.4-nano-2026-03-17") are
missing the reasoning price aliases; update each pricingTiers[0].prices object
to add "output_reasoning_tokens" and "output_reasoning" with the same numeric
value as the existing "output" key (so reasoning tokens/pricing match output),
then re-run "pnpm run sync-prices" to regenerate TypeScript artifacts.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx (2)

294-305: Tab buttons work correctly but lack explicit type="button".

While these buttons aren't inside a form (so they won't accidentally submit), adding explicit type="button" is a defensive practice that prevents potential issues if the component is ever used inside a form context.

♻️ Suggested change
             {availableTabs.map((tab) => (
               <button
                 key={tab}
+                type="button"
                 onClick={() => handleTabClick(tab)}
                 className={`px-2.5 py-1 text-[11px] capitalize transition-colors ${
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx` around lines 294 -
305, The tab buttons in AIChatMessages.tsx (the button rendered inside the map
that calls handleTabClick and uses activeTab) should explicitly include
type="button" to prevent accidental form submission if this component is ever
placed inside a form; update the JSX for the button elements that render the
tabs to add the type="button" attribute.

103-104: Consider using href instead of deprecated xlinkHref.

The xlink:href attribute is deprecated in SVG 2.0. Modern browsers support the href attribute directly. While xlinkHref still works for compatibility, you could simplify to href if legacy browser support isn't required.

♻️ Suggested change
-                  <use xlinkHref={`${tablerSpritePath}#tabler-file-text-ai`} />
+                  <use href={`${tablerSpritePath}#tabler-file-text-ai`} />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx` around lines 103 -
104, In AIChatMessages.tsx replace the deprecated SVG attribute usage on the
<use> element by swapping xlinkHref for href (keep the same value
`${tablerSpritePath}#tabler-file-text-ai`) so the <use> element uses href
instead of xlinkHref; update any other occurrences of xlinkHref in this
component to href to align with SVG2 and modern browsers while preserving the
tablerSpritePath reference.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/webapp/app/routes/api.v1.prompts`.$slug.override.ts:
- Around line 27-32: The loader currently returns a plain 405 Response without
CORS headers; change it to return the 405 through the existing apiCors helper so
the response includes CORS headers (i.e., wrap the 405 Response or a json
payload with apiCors(request, ...)). Update the loader function (export async
function loader) to call apiCors(request, ...) for the non-OPTIONS branch so its
405 response matches the action's CORS-wrapped 405 behavior.

---

Nitpick comments:
In `@apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx`:
- Around line 294-305: The tab buttons in AIChatMessages.tsx (the button
rendered inside the map that calls handleTabClick and uses activeTab) should
explicitly include type="button" to prevent accidental form submission if this
component is ever placed inside a form; update the JSX for the button elements
that render the tabs to add the type="button" attribute.
- Around line 103-104: In AIChatMessages.tsx replace the deprecated SVG
attribute usage on the <use> element by swapping xlinkHref for href (keep the
same value `${tablerSpritePath}#tabler-file-text-ai`) so the <use> element uses
href instead of xlinkHref; update any other occurrences of xlinkHref in this
component to href to align with SVG2 and modern browsers while preserving the
tablerSpritePath reference.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b384998e-aeb3-458a-8cbf-f030090af235

📥 Commits

Reviewing files that changed from the base of the PR and between 37e3367 and 3935f7e.

📒 Files selected for processing (3)
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx
  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (28)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (6, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (2, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (2, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (7, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (5, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (6, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (7, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (8, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (1, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (4, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (8, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (4, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (1, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (3, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (3, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (5, 8)
  • GitHub Check: units / packages / 🧪 Unit Tests: Packages (1, 1)
  • GitHub Check: sdk-compat / Deno Runtime
  • GitHub Check: sdk-compat / Node.js 20.20 (ubuntu-latest)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - pnpm)
  • GitHub Check: sdk-compat / Node.js 22.12 (ubuntu-latest)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - npm)
  • GitHub Check: typecheck / typecheck
  • GitHub Check: sdk-compat / Bun Runtime
  • GitHub Check: sdk-compat / Cloudflare Workers
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - pnpm)
  • GitHub Check: Analyze (javascript-typescript)
🧰 Additional context used
📓 Path-based instructions (11)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{ts,tsx}: Use types over interfaces for TypeScript
Avoid using enums; prefer string unions or const objects instead

**/*.{ts,tsx}: Use task export syntax: export const myTask = task({ id: 'my-task', run: async (payload) => { ... } })
Use Run Engine 2.0 (@internal/run-engine) and redis-worker for all new work - avoid DEPRECATED zodworker (Graphile-worker wrapper)
Prisma 6.14.0 client and schema use PostgreSQL in internal-packages/database - import only from Prisma client

Files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
{packages/core,apps/webapp}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use zod for validation in packages/core and apps/webapp

Files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use function declarations instead of default exports

Files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
apps/webapp/app/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

Access all environment variables through the env export of env.server.ts instead of directly accessing process.env in the Trigger.dev webapp

Files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
apps/webapp/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

apps/webapp/**/*.{ts,tsx}: When importing from @trigger.dev/core in the webapp, use subpath exports from the package.json instead of importing from the root path
Follow the Remix 2.1.0 and Express server conventions when updating the main trigger.dev webapp

Files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

**/*.ts: When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs
Do not use high-cardinality attributes in OTEL metrics such as UUIDs/IDs (envId, userId, runId, projectId, organizationId), unbounded integers (itemCount, batchSize, retryCount), timestamps (createdAt, startTime), or free-form strings (errorMessage, taskName, queueName)
When exporting OTEL metrics via OTLP to Prometheus, be aware that the exporter automatically adds unit suffixes to metric names (e.g., 'my_duration_ms' becomes 'my_duration_ms_milliseconds', 'my_counter' becomes 'my_counter_total'). Account for these transformations when writing Grafana dashboards or Prometheus queries

Files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
**/*.{js,ts,jsx,tsx,json,md,yaml,yml}

📄 CodeRabbit inference engine (AGENTS.md)

Format code using Prettier before committing

Files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
apps/webapp/app/routes/**/*.ts

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Use Remix flat-file route convention with dot-separated segments (e.g., api.v1.tasks.$taskId.trigger.ts for /api/v1/tasks/:taskId/trigger)

Files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
apps/{webapp,supervisor}/**/*

📄 CodeRabbit inference engine (CLAUDE.md)

When modifying only server components (apps/webapp/, apps/supervisor/) with no package changes, add a .server-changes/ file instead of a changeset

Files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
**/*.{ts,tsx,js}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx,js}: Always import from @trigger.dev/sdk for Trigger.dev tasks - never use @trigger.dev/sdk/v3 or deprecated client.defineJob
Import subpaths only from @trigger.dev/core, never import from root
Add crumbs as you write code using // @crumbs comments or // #region @crumbs blocks for agentcrumbs debug tracing

Files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
apps/webapp/**/*.{ts,tsx,jsx,js}

📄 CodeRabbit inference engine (CLAUDE.md)

Remix 2.1.0 is used in apps/webapp for the main API, dashboard, and orchestration with Express server

Files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
🧠 Learnings (5)
📚 Learning: 2026-03-02T12:42:56.114Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: apps/webapp/CLAUDE.md:0-0
Timestamp: 2026-03-02T12:42:56.114Z
Learning: Applies to apps/webapp/app/routes/**/*.ts : Use Remix flat-file route convention with dot-separated segments (e.g., `api.v1.tasks.$taskId.trigger.ts` for `/api/v1/tasks/:taskId/trigger`)

Applied to files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
📚 Learning: 2026-03-13T13:37:49.562Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T13:37:49.562Z
Learning: Applies to apps/webapp/**/*.{ts,tsx,jsx,js} : Remix 2.1.0 is used in apps/webapp for the main API, dashboard, and orchestration with Express server

Applied to files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
📚 Learning: 2025-11-27T16:26:58.661Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/webapp.mdc:0-0
Timestamp: 2025-11-27T16:26:58.661Z
Learning: Applies to apps/webapp/**/*.{ts,tsx} : Follow the Remix 2.1.0 and Express server conventions when updating the main trigger.dev webapp

Applied to files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
📚 Learning: 2026-03-13T13:45:39.411Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3213
File: apps/webapp/app/routes/admin.llm-models.missing.$model.tsx:19-21
Timestamp: 2026-03-13T13:45:39.411Z
Learning: In `apps/webapp/app/routes/admin.llm-models.missing.$model.tsx`, the `decodeURIComponent(params.model ?? "")` call is intentionally unguarded. Remix route params are decoded at the routing layer before reaching the loader, so malformed percent-encoding is rejected upstream. The page is also admin-only, so the risk is minimal and no try-catch is warranted.

Applied to files:

  • apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
📚 Learning: 2026-02-11T16:37:32.429Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3019
File: apps/webapp/app/components/primitives/charts/Card.tsx:26-30
Timestamp: 2026-02-11T16:37:32.429Z
Learning: In projects using react-grid-layout, avoid relying on drag-handle class to imply draggability. Ensure drag-handle elements only affect dragging when the parent grid item is configured draggable in the layout; conditionally apply cursor styles based on the draggable prop. This improves correctness and accessibility.

Applied to files:

  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
🔇 Additional comments (8)
apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx (3)

27-31: LGTM!

Clean type definition using type as per coding guidelines.


113-119: Accessibility attributes properly implemented.

The expand/collapse button now includes aria-label and aria-expanded attributes, providing an accessible name for screen readers and communicating the current state.


223-230: Good fix for HTML nesting compliance.

Changing from <Paragraph> (which renders a <p>) to a <div> correctly addresses the HTML validation warning, since StreamdownRenderer likely produces block-level elements that cannot be nested inside <p> tags. The typography classes are preserved.

apps/webapp/app/routes/api.v1.prompts.$slug.override.ts (5)

1-8: LGTM!

Imports are correctly structured: Remix types from @remix-run/server-runtime, Zod for validation, Prisma client, and service imports from appropriate local paths.


10-25: LGTM!

Zod schemas properly define validation for route params and request bodies. Using z.object aligns with the coding guideline to prefer types over interfaces.


63-86: LGTM!

The POST handler correctly wraps request.json() in try-catch and validates with safeParse. The createOverride call is intentionally not wrapped since it doesn't throw ServiceValidationError — any database errors would correctly surface as 500s.


88-116: LGTM!

The PUT/PATCH handler correctly catches ServiceValidationError since updateOverride throws this when no active override exists. The error handling properly preserves the service's status code.


118-127: LGTM!

The DELETE handler and method-not-allowed fallback are correctly implemented. The Allow header now includes all supported methods.

Comment on lines +27 to +32
export async function loader({ request }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
return new Response(null, { status: 405 });
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Loader's 405 response lacks CORS headers.

The loader returns a plain Response without CORS headers for non-OPTIONS methods (line 31), while the action wraps its 405 response with apiCors (line 123-126). This inconsistency means GET requests will receive a 405 without CORS headers, potentially causing CORS errors for browser clients.

🔧 Proposed fix to add CORS headers
 export async function loader({ request }: LoaderFunctionArgs) {
   if (request.method.toUpperCase() === "OPTIONS") {
     return apiCors(request, json({}));
   }
-  return new Response(null, { status: 405 });
+  return apiCors(
+    request,
+    json({ error: "Method not allowed" }, { status: 405, headers: { Allow: "POST, PUT, PATCH, DELETE" } })
+  );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/routes/api.v1.prompts`.$slug.override.ts around lines 27 -
32, The loader currently returns a plain 405 Response without CORS headers;
change it to return the 405 through the existing apiCors helper so the response
includes CORS headers (i.e., wrap the 405 Response or a json payload with
apiCors(request, ...)). Update the loader function (export async function
loader) to call apiCors(request, ...) for the non-OPTIONS branch so its 405
response matches the action's CORS-wrapped 405 behavior.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (4)
apps/webapp/app/presenters/v3/PromptPresenter.server.ts (2)

322-327: Consider improving type safety and error logging context.

The as any cast bypasses TypeScript's type checking. Additionally, error logs would benefit from including the query context (environmentId, promptSlug) for easier debugging.

♻️ Suggested improvement
-    const [error, rows] = await queryFn(queryParams as any);
+    const [error, rows] = await queryFn(
+      queryParams as z.infer<typeof queryFn extends (...args: infer P) => unknown ? P[0] : never>
+    );

     if (error) {
-      console.error("Prompt generations query failed:", error);
+      console.error("Prompt generations query failed:", {
+        error,
+        environmentId: options.environmentId,
+        promptSlug: options.promptSlug,
+      });
       return { generations: [], pagination: {} };
     }

Alternatively, consider restructuring the params schema to avoid dynamic construction, or use a discriminated union approach.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/presenters/v3/PromptPresenter.server.ts` around lines 322 -
327, The call to queryFn currently suppresses type checks by casting queryParams
as any and logs errors without context; update the code to give queryParams a
proper type matching queryFn’s parameter (or create a discriminated
union/explicit interface used by queryFn) instead of using "as any", then call
queryFn(queryParams) with the correctly typed object; also enrich the error log
in the error branch to include context (e.g., environmentId and promptSlug)
alongside the error so the console.error in this block clearly shows "Prompt
generations query failed" plus environmentId and promptSlug and the error
object, and ensure the return shape ({ generations, pagination }) stays the
same.

362-365: Add error logging for consistency.

Other methods (getUsageSparklines, listGenerations) log errors before returning fallback values. This method silently swallows errors, making debugging harder.

♻️ Suggested fix
     const [error, rows] = await queryFn({ organizationId, projectId, environmentId });
     if (error) {
+      console.error("getDistinctPromptSlugs query failed:", error);
       return [];
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/presenters/v3/PromptPresenter.server.ts` around lines 362 -
365, This block silently swallows errors from queryFn; before returning [], log
the error like the other methods (getUsageSparklines, listGenerations) do: call
the same logger used in this file (e.g., processLogger or the module logger) to
log a descriptive message plus the error and relevant context (organizationId,
projectId, environmentId) so you capture the failure details, then return the
empty array as the fallback.
apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts (1)

64-79: Parse ai.prompt once and reuse it for both items and messageCount.

promptJson is parsed twice through separate logic paths. Centralizing parse+derive avoids drift between displayed items and messageCount behavior.

Also applies to: 144-193

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts` around
lines 64 - 79, Parse ai.prompt once into a single variable (e.g., parsedPrompt
or parsedPromptJson) and reuse it for both items and messageCount to avoid
double-parsing; replace the current separate JSON.parse usage with a single
parse step, pass that parsed object into parsePromptToDisplayItems (instead of
re-stringifying) and compute messageCount from the same parsed object (checking
parsed.messages array or falling back to system/prompt fields), and apply the
same change for the similar logic around the block at lines 144-193 so both
display items and message count are derived from the same parsedPrompt.
apps/webapp/app/v3/services/promptService.server.ts (1)

1-5: Move this service to the folderized v3 service layout.

apps/webapp/app/v3/services/promptService.server.ts does not follow the app/v3/services/*/*.server.ts convention used for service organization.

As per coding guidelines apps/webapp/app/v3/services/**/*.server.{ts,tsx}: Organize services in the webapp following the pattern app/v3/services/*/*.server.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/v3/services/promptService.server.ts` around lines 1 - 5, The
PromptService class currently lives flat and must be moved into the v3 services
folderized layout; create a new subfolder for this domain (e.g., a prompt
subfolder) and move the PromptService (class PromptService) file there, keeping
its exports and preserving imports of createHash, prisma, BaseService and
ServiceValidationError; update all import sites that reference the old module
path to the new subfolder path and adjust any relative import paths inside the
moved file so imports (createHash, prisma, BaseService, ServiceValidationError)
continue to resolve.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts`:
- Around line 15-34: The current guard only checks truthiness of ai.operationId
but can allow non-string values and later produce an empty operationName; update
the early check to explicitly verify ai.operationId is a non-empty string (e.g.,
typeof ai.operationId === "string" && ai.operationId.trim() !== "") and return
undefined if it fails, and then use that validated value for operationName (via
str(ai.operationId)) so AISpanData always receives a valid non-empty
operationName; modify the checks around ai.operationId and the assignment to
operationName in extractAISummarySpanData to implement this.
- Around line 225-242: resolvedProvider is declared but never assigned; populate
it from the parsed metadata before returning. In extractAISummarySpanData
(variable resolvedProvider), set resolvedProvider = parsed.provider ||
parsed.openai?.provider || parsed.openai?.resolvedProvider ||
parsed.anthropic?.provider || parsed.anthropic?.resolvedProvider (or other
provider-specific field names your metadata uses) so the return expression
(serviceTier || resolvedProvider || responseId) can actually surface a provider
value; ensure you check each possible location on the parsed object (parsed,
parsed.openai, parsed.anthropic) and only assign when a string exists.
- Around line 149-150: Replace all manual JSON.parse + typeof checks in
extractAISummarySpanData with zod schemas: define Zod schemas that match the
expected shapes for promptJson (used where const parsed = JSON.parse(promptJson)
as Record<string, unknown>), the data parsed around lines 221–223, and the
structures parsed at 252–266; use schema.safeParse(...) instead of JSON.parse
and guard on result.success, returning undefined or typed value on failure.
Update references to the parsed variables (e.g., parsed) to use the typed .data
from safeParse so callers of extractAISummarySpanData (and local helpers) get
proper typed objects.

In `@apps/webapp/app/presenters/v3/PromptPresenter.server.ts`:
- Around line 100-141: The ClickHouse bucket timestamps are produced in the
server timezone while bucketKeys are generated in UTC (toISOString), causing
mismatches; update the query to force UTC on the aggregation (e.g., change
toStartOfHour(start_time) to toStartOfHour(toTimeZone(start_time, 'UTC')) or
toStartOfHour(start_time, 'UTC')) so the returned bucket field uses UTC, leaving
the bucketKeys generation (h.toISOString().slice(0,13).replace("T"," ") +
":00:00") as-is; modify the query SQL in the query object where toStartOfHour is
used so prompt usage sparkline rows align with the UTC bucketKeys.

In `@apps/webapp/app/v3/services/promptService.server.ts`:
- Around line 6-24: promoteVersion currently loads the version by versionId but
then mutates labels by promptId, which can relabel the wrong prompt and leave
partial state; modify promoteVersion to first load the PromptVersion via
this._prisma.promptVersion.findUnique (or include promptId) and verify
target.promptId === promptId (throw ServiceValidationError if not), then perform
the relabeling inside a single database transaction (use
this._prisma.$transaction) so `#removeLabel` and `#addLabel` are executed atomically
(or replace with a single transactional update that clears "current" on the old
prompt and sets "current" on the target version) to avoid partial state.

---

Nitpick comments:
In `@apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts`:
- Around line 64-79: Parse ai.prompt once into a single variable (e.g.,
parsedPrompt or parsedPromptJson) and reuse it for both items and messageCount
to avoid double-parsing; replace the current separate JSON.parse usage with a
single parse step, pass that parsed object into parsePromptToDisplayItems
(instead of re-stringifying) and compute messageCount from the same parsed
object (checking parsed.messages array or falling back to system/prompt fields),
and apply the same change for the similar logic around the block at lines
144-193 so both display items and message count are derived from the same
parsedPrompt.

In `@apps/webapp/app/presenters/v3/PromptPresenter.server.ts`:
- Around line 322-327: The call to queryFn currently suppresses type checks by
casting queryParams as any and logs errors without context; update the code to
give queryParams a proper type matching queryFn’s parameter (or create a
discriminated union/explicit interface used by queryFn) instead of using "as
any", then call queryFn(queryParams) with the correctly typed object; also
enrich the error log in the error branch to include context (e.g., environmentId
and promptSlug) alongside the error so the console.error in this block clearly
shows "Prompt generations query failed" plus environmentId and promptSlug and
the error object, and ensure the return shape ({ generations, pagination })
stays the same.
- Around line 362-365: This block silently swallows errors from queryFn; before
returning [], log the error like the other methods (getUsageSparklines,
listGenerations) do: call the same logger used in this file (e.g., processLogger
or the module logger) to log a descriptive message plus the error and relevant
context (organizationId, projectId, environmentId) so you capture the failure
details, then return the empty array as the fallback.

In `@apps/webapp/app/v3/services/promptService.server.ts`:
- Around line 1-5: The PromptService class currently lives flat and must be
moved into the v3 services folderized layout; create a new subfolder for this
domain (e.g., a prompt subfolder) and move the PromptService (class
PromptService) file there, keeping its exports and preserving imports of
createHash, prisma, BaseService and ServiceValidationError; update all import
sites that reference the old module path to the new subfolder path and adjust
any relative import paths inside the moved file so imports (createHash, prisma,
BaseService, ServiceValidationError) continue to resolve.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 7e552d78-3916-4562-b6c5-46a43cf3f9e5

📥 Commits

Reviewing files that changed from the base of the PR and between 3935f7e and 7a681c2.

📒 Files selected for processing (6)
  • apps/webapp/app/components/navigation/sideMenuTypes.ts
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/v3/utils/enrichCreatableEvents.server.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/webapp/app/v3/utils/enrichCreatableEvents.server.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (28)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (4, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (6, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (7, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (8, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (5, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (1, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (3, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (3, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (7, 8)
  • GitHub Check: units / internal / 🧪 Unit Tests: Internal (2, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (8, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (1, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (2, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (4, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (6, 8)
  • GitHub Check: units / webapp / 🧪 Unit Tests: Webapp (5, 8)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - npm)
  • GitHub Check: units / packages / 🧪 Unit Tests: Packages (1, 1)
  • GitHub Check: sdk-compat / Node.js 20.20 (ubuntu-latest)
  • GitHub Check: sdk-compat / Bun Runtime
  • GitHub Check: sdk-compat / Node.js 22.12 (ubuntu-latest)
  • GitHub Check: sdk-compat / Cloudflare Workers
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - pnpm)
  • GitHub Check: sdk-compat / Deno Runtime
  • GitHub Check: typecheck / typecheck
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - pnpm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - npm)
  • GitHub Check: Analyze (javascript-typescript)
🧰 Additional context used
📓 Path-based instructions (14)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{ts,tsx}: Use types over interfaces for TypeScript
Avoid using enums; prefer string unions or const objects instead

**/*.{ts,tsx}: Use task export syntax: export const myTask = task({ id: 'my-task', run: async (payload) => { ... } })
Use Run Engine 2.0 (@internal/run-engine) and redis-worker for all new work - avoid DEPRECATED zodworker (Graphile-worker wrapper)
Prisma 6.14.0 client and schema use PostgreSQL in internal-packages/database - import only from Prisma client

Files:

  • apps/webapp/app/components/navigation/sideMenuTypes.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
{packages/core,apps/webapp}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use zod for validation in packages/core and apps/webapp

Files:

  • apps/webapp/app/components/navigation/sideMenuTypes.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use function declarations instead of default exports

Files:

  • apps/webapp/app/components/navigation/sideMenuTypes.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
apps/webapp/app/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

Access all environment variables through the env export of env.server.ts instead of directly accessing process.env in the Trigger.dev webapp

Files:

  • apps/webapp/app/components/navigation/sideMenuTypes.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
apps/webapp/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

apps/webapp/**/*.{ts,tsx}: When importing from @trigger.dev/core in the webapp, use subpath exports from the package.json instead of importing from the root path
Follow the Remix 2.1.0 and Express server conventions when updating the main trigger.dev webapp

Files:

  • apps/webapp/app/components/navigation/sideMenuTypes.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

**/*.ts: When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs
Do not use high-cardinality attributes in OTEL metrics such as UUIDs/IDs (envId, userId, runId, projectId, organizationId), unbounded integers (itemCount, batchSize, retryCount), timestamps (createdAt, startTime), or free-form strings (errorMessage, taskName, queueName)
When exporting OTEL metrics via OTLP to Prometheus, be aware that the exporter automatically adds unit suffixes to metric names (e.g., 'my_duration_ms' becomes 'my_duration_ms_milliseconds', 'my_counter' becomes 'my_counter_total'). Account for these transformations when writing Grafana dashboards or Prometheus queries

Files:

  • apps/webapp/app/components/navigation/sideMenuTypes.ts
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
**/*.{js,ts,jsx,tsx,json,md,yaml,yml}

📄 CodeRabbit inference engine (AGENTS.md)

Format code using Prettier before committing

Files:

  • apps/webapp/app/components/navigation/sideMenuTypes.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
apps/{webapp,supervisor}/**/*

📄 CodeRabbit inference engine (CLAUDE.md)

When modifying only server components (apps/webapp/, apps/supervisor/) with no package changes, add a .server-changes/ file instead of a changeset

Files:

  • apps/webapp/app/components/navigation/sideMenuTypes.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
**/*.{ts,tsx,js}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx,js}: Always import from @trigger.dev/sdk for Trigger.dev tasks - never use @trigger.dev/sdk/v3 or deprecated client.defineJob
Import subpaths only from @trigger.dev/core, never import from root
Add crumbs as you write code using // @crumbs comments or // #region @crumbs blocks for agentcrumbs debug tracing

Files:

  • apps/webapp/app/components/navigation/sideMenuTypes.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
apps/webapp/**/*.{ts,tsx,jsx,js}

📄 CodeRabbit inference engine (CLAUDE.md)

Remix 2.1.0 is used in apps/webapp for the main API, dashboard, and orchestration with Express server

Files:

  • apps/webapp/app/components/navigation/sideMenuTypes.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
apps/webapp/app/v3/services/**/*.server.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

Organize services in the webapp following the pattern app/v3/services/*/*.server.ts

Files:

  • apps/webapp/app/v3/services/promptService.server.ts
apps/webapp/**/*.server.ts

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Access environment variables via the env export from app/env.server.ts, never use process.env directly

Files:

  • apps/webapp/app/v3/services/promptService.server.ts
  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
apps/webapp/app/v3/services/**/*.server.ts

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

When editing services that branch on RunEngineVersion to support both V1 and V2 (e.g., cancelTaskRun.server.ts, batchTriggerV3.server.ts), only modify V2 code paths

Files:

  • apps/webapp/app/v3/services/promptService.server.ts
apps/webapp/app/v3/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

When modifying V3 code paths in apps/webapp/app/v3/, only modify V2 code - consult apps/webapp/CLAUDE.md for V1-only legacy code to avoid

Files:

  • apps/webapp/app/v3/services/promptService.server.ts
🧠 Learnings (18)
📚 Learning: 2025-11-27T16:26:37.432Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-11-27T16:26:37.432Z
Learning: Applies to {packages/core,apps/webapp}/**/*.{ts,tsx} : Use zod for validation in packages/core and apps/webapp

Applied to files:

  • apps/webapp/app/components/navigation/sideMenuTypes.ts
📚 Learning: 2025-12-08T15:19:56.823Z
Learnt from: 0ski
Repo: triggerdotdev/trigger.dev PR: 2760
File: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx:278-281
Timestamp: 2025-12-08T15:19:56.823Z
Learning: In apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx, the tableState search parameter uses intentional double-encoding: the parameter value contains a URL-encoded URLSearchParams string, so decodeURIComponent(value("tableState") ?? "") is required to fully decode it before parsing with new URLSearchParams(). This pattern allows bundling multiple filter/pagination params as a single search parameter.

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
📚 Learning: 2026-02-03T18:27:40.429Z
Learnt from: 0ski
Repo: triggerdotdev/trigger.dev PR: 2994
File: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx:553-555
Timestamp: 2026-02-03T18:27:40.429Z
Learning: In apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx, the menu buttons (e.g., Edit with PencilSquareIcon) in the TableCellMenu are intentionally icon-only with no text labels as a compact UI pattern. This is a deliberate design choice for this route; preserve the icon-only behavior for consistency in this file.

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
📚 Learning: 2026-02-11T16:50:14.167Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3019
File: apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx:126-131
Timestamp: 2026-02-11T16:50:14.167Z
Learning: In apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx, MetricsDashboard entities are intentionally scoped to the organization level, not the project level. The dashboard lookup should filter by organizationId only (not projectId), allowing dashboards to be accessed across projects within the same organization. The optional projectId field on MetricsDashboard serves other purposes and should not be used as an authorization constraint.

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
📚 Learning: 2026-03-13T13:37:49.562Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T13:37:49.562Z
Learning: Applies to **/*.{ts,tsx} : Use Run Engine 2.0 (internal/run-engine) and redis-worker for all new work - avoid DEPRECATED zodworker (Graphile-worker wrapper)

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
📚 Learning: 2026-02-10T16:18:48.654Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 2980
File: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx:512-515
Timestamp: 2026-02-10T16:18:48.654Z
Learning: In apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx, environment.queueSizeLimit is a per-queue maximum that is configured at the environment level, not a shared limit across all queues. Each queue can have up to environment.queueSizeLimit items queued independently.

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
📚 Learning: 2026-03-02T12:42:56.114Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: apps/webapp/CLAUDE.md:0-0
Timestamp: 2026-03-02T12:42:56.114Z
Learning: Applies to apps/webapp/app/routes/**/*.ts : Use Remix flat-file route convention with dot-separated segments (e.g., `api.v1.tasks.$taskId.trigger.ts` for `/api/v1/tasks/:taskId/trigger`)

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
📚 Learning: 2026-02-04T16:34:48.876Z
Learnt from: 0ski
Repo: triggerdotdev/trigger.dev PR: 2994
File: apps/webapp/app/routes/vercel.connect.tsx:13-27
Timestamp: 2026-02-04T16:34:48.876Z
Learning: In apps/webapp/app/routes/vercel.connect.tsx, configurationId may be absent for "dashboard" flows but must be present for "marketplace" flows. Enforce this with a Zod superRefine and pass installationId to repository methods only when configurationId is defined (omit the field otherwise).

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
📚 Learning: 2025-11-27T16:26:58.661Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/webapp.mdc:0-0
Timestamp: 2025-11-27T16:26:58.661Z
Learning: Applies to apps/webapp/**/*.{ts,tsx} : Follow the Remix 2.1.0 and Express server conventions when updating the main trigger.dev webapp

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
📚 Learning: 2025-11-27T16:27:35.304Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/writing-tasks.mdc:0-0
Timestamp: 2025-11-27T16:27:35.304Z
Learning: Applies to **/trigger/**/*.{ts,tsx,js,jsx} : Use `metadata.parent` and `metadata.root` to update parent and root task metadata from child tasks

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
📚 Learning: 2026-02-11T16:37:32.429Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3019
File: apps/webapp/app/components/primitives/charts/Card.tsx:26-30
Timestamp: 2026-02-11T16:37:32.429Z
Learning: In projects using react-grid-layout, avoid relying on drag-handle class to imply draggability. Ensure drag-handle elements only affect dragging when the parent grid item is configured draggable in the layout; conditionally apply cursor styles based on the draggable prop. This improves correctness and accessibility.

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
📚 Learning: 2026-03-13T13:43:06.471Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3213
File: apps/webapp/app/components/runs/v3/ai/extractAISpanData.ts:52-52
Timestamp: 2026-03-13T13:43:06.471Z
Learning: In the trigger.dev codebase (PR `#3213`), `extractAISpanData.ts` (`apps/webapp/app/components/runs/v3/ai/extractAISpanData.ts`) is a read-side UI helper that reads already-enriched `trigger.llm.*` span attributes for display. The actual LLM cost computation and gateway/OpenRouter cost fallback logic lives in `enrichCreatableEvents.server.ts` (`apps/webapp/app/v3/utils/enrichCreatableEvents.server.ts`) via `extractProviderCost()`. The `gatewayCost` parsed in `extractAISpanData` is for UI display purposes only, not for cost calculation.

Applied to files:

  • apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
📚 Learning: 2026-03-02T12:42:56.114Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: apps/webapp/CLAUDE.md:0-0
Timestamp: 2026-03-02T12:42:56.114Z
Learning: Applies to apps/webapp/app/v3/services/**/*.server.ts : When editing services that branch on `RunEngineVersion` to support both V1 and V2 (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`), only modify V2 code paths

Applied to files:

  • apps/webapp/app/v3/services/promptService.server.ts
📚 Learning: 2026-03-02T12:43:34.140Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: packages/cli-v3/CLAUDE.md:0-0
Timestamp: 2026-03-02T12:43:34.140Z
Learning: Applies to packages/cli-v3/src/commands/promote.ts : Implement `promote.ts` command in `src/commands/` for deployment promotion

Applied to files:

  • apps/webapp/app/v3/services/promptService.server.ts
📚 Learning: 2026-03-10T17:56:20.938Z
Learnt from: samejr
Repo: triggerdotdev/trigger.dev PR: 3201
File: apps/webapp/app/v3/services/setSeatsAddOn.server.ts:25-29
Timestamp: 2026-03-10T17:56:20.938Z
Learning: Do not implement local userId-to-organizationId authorization checks inside org-scoped service classes (e.g., SetSeatsAddOnService, SetBranchesAddOnService) in the web app. Rely on route-layer authentication (requireUserId(request)) and org membership enforcement via the _app.orgs.$organizationSlug layout route. Any userId/organizationId that reaches these services from org-scoped routes has already been validated. Apply this pattern across all org-scoped services to avoid redundant auth checks and maintain consistency.

Applied to files:

  • apps/webapp/app/v3/services/promptService.server.ts
📚 Learning: 2025-11-27T16:26:58.661Z
Learnt from: CR
Repo: triggerdotdev/trigger.dev PR: 0
File: .cursor/rules/webapp.mdc:0-0
Timestamp: 2025-11-27T16:26:58.661Z
Learning: Applies to apps/webapp/app/v3/presenters/**/*.server.{ts,tsx} : Organize presenters in the webapp following the pattern `app/v3/presenters/*/*.server.ts` to move complex loader code into classes

Applied to files:

  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
📚 Learning: 2025-07-12T18:06:04.133Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 2264
File: apps/webapp/app/services/runsRepository.server.ts:172-174
Timestamp: 2025-07-12T18:06:04.133Z
Learning: In apps/webapp/app/services/runsRepository.server.ts, the in-memory status filtering after fetching runs from Prisma is intentionally used as a workaround for ClickHouse data delays. This approach is acceptable because the result set is limited to a maximum of 100 runs due to pagination, making the performance impact negligible.

Applied to files:

  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
📚 Learning: 2026-02-06T19:53:38.843Z
Learnt from: 0ski
Repo: triggerdotdev/trigger.dev PR: 2994
File: apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts:233-237
Timestamp: 2026-02-06T19:53:38.843Z
Learning: When constructing Vercel dashboard URLs from deployment IDs, always strip the dpl_ prefix from the ID. Implement this by transforming the ID with .replace(/^dpl_/, "") before concatenating into the URL: https://vercel.com/${teamSlug}/${projectName}/${cleanedDeploymentId}. Consider centralizing this logic in a small helper (e.g., getVercelDeploymentId(id) or a URL builder) and add tests to verify both prefixed and non-prefixed inputs.

Applied to files:

  • apps/webapp/app/presenters/v3/PromptPresenter.server.ts
🔇 Additional comments (14)
apps/webapp/app/components/navigation/sideMenuTypes.ts (1)

4-4: Looks good — schema and inferred type stay in sync with the new AI section.

Adding "ai" to SideMenuSectionIdSchema cleanly updates both validation and SideMenuSectionId without extra type maintenance.

apps/webapp/app/presenters/v3/PromptPresenter.server.ts (7)

1-36: LGTM!

The imports and schema definitions are well-structured. The zod schema properly uses z.coerce.number() for numeric fields that may come as strings from ClickHouse. The field name difference between started_at (schema) and start_time (exported type) is handled correctly via mapping at lines 332-335.


38-44: LGTM!

Constructor properly initializes the ClickHouse client and passes the replica to the base presenter.


46-90: LGTM!

Clean implementation with proper model resolution priority (override → current → default). The Prisma query efficiently fetches only relevant versions using the hasSome filter.


157-193: LGTM!

Version resolution logic correctly prioritizes: explicit version → override label → specified/default label. The ordering by version descending ensures the latest matching version is returned.


195-212: LGTM!

Clean implementation with appropriate field selection and sensible default limit.


229-233: LGTM!

The keyset pagination is correctly implemented using a composite cursor (start_time, span_id) that handles timestamp ties properly.

Also applies to: 275-276, 338-341


370-384: LGTM!

Cursor encoding/decoding is well-implemented with proper error handling for malformed input and compact property names to minimize cursor size.

apps/webapp/app/v3/services/promptService.server.ts (2)

120-134: Label helpers are idempotent and safely guarded.

array_remove/array_append with ANY("labels") checks keeps label updates safe from duplicate append/remove behavior.


37-40: [Rewritten comment]
[Exactly ONE classification tag]

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx (4)

54-54: LGTM!

Clean import of the ResizableSnapshot type and consistent destructuring pattern for the new resizable loader data.

Also applies to: 307-308


512-512: LGTM – type assertions for serialized loader data.

The as ResizableSnapshot assertions are appropriate here since SerializeFrom<typeof loader> alters the types during serialization. This is a standard Remix pattern for cookie/storage-persisted data.

Also applies to: 542-542


568-568: LGTM!

Consistent implementation with TraceView – the resizable snapshot persistence is correctly wired for the no-logs fallback view.

Also applies to: 588-588


679-680: LGTM!

The optional treeSnapshot prop is correctly typed and properly passed to the inner ResizablePanelGroup. This enables persisting the tree/timeline split sizes independently from the parent panel.

Also applies to: 780-780

Comment on lines +15 to +34
if (!ai.operationId) return undefined;

// Skip child spans that have gen_ai.* (those use extractAISpanData)
if (properties.gen_ai && typeof properties.gen_ai === "object") return undefined;

const aiModel = rec(ai.model);
const aiResponse = rec(ai.response);
const aiUsage = rec(ai.usage);
const aiSettings = rec(ai.settings);
const aiRequest = rec(ai.request);
const aiTelemetry = rec(ai.telemetry);
const trigger = rec(properties.trigger);
const triggerLlm = rec(trigger.llm);

const model = str(aiModel.id);
if (!model) return undefined;

const provider = str(aiModel.provider) ?? "unknown";
const operationName = str(ai.operationId) ?? "";

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Tighten ai.operationId validation to avoid empty operationName.

Line 15 checks truthiness only; a non-string truthy value passes, and Line 33 then falls back to "". Since operationName is required on AISpanData, guard for string upfront and return undefined otherwise.

Proposed fix
   const ai = rec(properties.ai);
-  if (!ai.operationId) return undefined;
+  const operationName = str(ai.operationId);
+  if (!operationName) return undefined;
@@
-  const operationName = str(ai.operationId) ?? "";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts` around
lines 15 - 34, The current guard only checks truthiness of ai.operationId but
can allow non-string values and later produce an empty operationName; update the
early check to explicitly verify ai.operationId is a non-empty string (e.g.,
typeof ai.operationId === "string" && ai.operationId.trim() !== "") and return
undefined if it fails, and then use that validated value for operationName (via
str(ai.operationId)) so AISpanData always receives a valid non-empty
operationName; modify the checks around ai.operationId and the assignment to
operationName in extractAISummarySpanData to implement this.

Comment on lines +149 to +150
const parsed = JSON.parse(promptJson) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object") return undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify current manual JSON parsing and whether zod is used in this area.
rg -n --type=ts 'JSON\.parse\(|as Record<string, unknown>|typeof .*=== "object"' apps/webapp/app/components/runs/v3/ai
rg -n --type=ts 'from "zod"|from '\''zod'\''' apps/webapp/app/components/runs/v3/ai

Repository: triggerdotdev/trigger.dev

Length of output: 4179


🏁 Script executed:

# Read the specific file to see exact code at flagged lines
sed -n '145,155p' apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
sed -n '218,225p' apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts
sed -n '249,270p' apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts

Repository: triggerdotdev/trigger.dev

Length of output: 1329


🏁 Script executed:

# Check if similar validation patterns use zod elsewhere in webapp
rg -n 'import.*from.*zod' apps/webapp/app/components/runs/ -l | head -5
rg -n 'z\.parse\(|safeParse\(' apps/webapp/app/components/runs/v3/ -l | head -5

Repository: triggerdotdev/trigger.dev

Length of output: 391


🏁 Script executed:

# Check if extractAISpanData.ts (similar file) uses zod
head -30 apps/webapp/app/components/runs/v3/ai/extractAISpanData.ts

Repository: triggerdotdev/trigger.dev

Length of output: 1190


Replace manual JSON validation with zod schemas throughout this file.

This file uses repeated patterns of JSON.parse() with type assertions and manual typeof guards for validation. Per coding guidelines, apps/webapp must use zod for validation. Apply this to all parsing instances including lines 149–150, 221–223, and 252–266.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts` around
lines 149 - 150, Replace all manual JSON.parse + typeof checks in
extractAISummarySpanData with zod schemas: define Zod schemas that match the
expected shapes for promptJson (used where const parsed = JSON.parse(promptJson)
as Record<string, unknown>), the data parsed around lines 221–223, and the
structures parsed at 252–266; use schema.safeParse(...) instead of JSON.parse
and guard on result.success, returning undefined or typed value on failure.
Update references to the parsed variables (e.g., parsed) to use the typed .data
from safeParse so callers of extractAISummarySpanData (and local helpers) get
proper typed objects.

Comment on lines +225 to +242
let serviceTier: string | undefined;
let resolvedProvider: string | undefined;
let responseId: string | undefined;

// OpenAI
if (parsed.openai) {
serviceTier = parsed.openai.serviceTier;
responseId = parsed.openai.responseId;
}

// Anthropic
if (parsed.anthropic?.usage?.service_tier) {
serviceTier = parsed.anthropic.usage.service_tier;
}

return serviceTier || resolvedProvider || responseId
? { serviceTier, resolvedProvider, responseId }
: undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

resolvedProvider is never populated in provider metadata parsing.

resolvedProvider is declared (Line 226) and returned (Line 241) but never assigned, so this field is always undefined even when metadata contains it.

Proposed fix
 function parseProviderMetadata(
   raw: unknown
 ): { serviceTier?: string; resolvedProvider?: string; responseId?: string } | undefined {
   const jsonStr = str(raw);
   if (!jsonStr) return undefined;

   try {
-    const parsed = JSON.parse(jsonStr) as Record<string, any>;
+    const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
     if (!parsed || typeof parsed !== "object") return undefined;

     let serviceTier: string | undefined;
     let resolvedProvider: string | undefined;
     let responseId: string | undefined;
@@
     if (parsed.anthropic?.usage?.service_tier) {
       serviceTier = parsed.anthropic.usage.service_tier;
     }

+    // Gateway/OpenRouter-style resolved provider hints
+    const openrouter = rec((parsed as Record<string, unknown>).openrouter);
+    const gateway = rec((parsed as Record<string, unknown>).gateway);
+    resolvedProvider =
+      str((parsed as Record<string, unknown>).resolvedProvider) ??
+      str(openrouter.provider) ??
+      str(gateway.provider);
+
     return serviceTier || resolvedProvider || responseId
       ? { serviceTier, resolvedProvider, responseId }
       : undefined;
   } catch {
     return undefined;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/runs/v3/ai/extractAISummarySpanData.ts` around
lines 225 - 242, resolvedProvider is declared but never assigned; populate it
from the parsed metadata before returning. In extractAISummarySpanData (variable
resolvedProvider), set resolvedProvider = parsed.provider ||
parsed.openai?.provider || parsed.openai?.resolvedProvider ||
parsed.anthropic?.provider || parsed.anthropic?.resolvedProvider (or other
provider-specific field names your metadata uses) so the return expression
(serviceTier || resolvedProvider || responseId) can actually surface a provider
value; ensure you check each possible location on the parsed object (parsed,
parsed.openai, parsed.anthropic) and only assign when a string exists.

Comment on lines +100 to +141
query: `SELECT
prompt_slug,
toStartOfHour(start_time) AS bucket,
count() AS cnt
FROM trigger_dev.llm_metrics_v1
WHERE environment_id = {environmentId: String}
AND prompt_slug IN {promptSlugs: Array(String)}
AND start_time >= now() - INTERVAL 24 HOUR
GROUP BY prompt_slug, bucket
ORDER BY prompt_slug, bucket`,
params: z.object({
environmentId: z.string(),
promptSlugs: z.array(z.string()),
}),
schema: z.object({
prompt_slug: z.string(),
bucket: z.string(),
cnt: z.coerce.number(),
}),
});

const [error, rows] = await queryFn({ environmentId, promptSlugs });
if (error) {
console.error("Prompt usage sparkline query failed:", error);
return {};
}

// Build a map of slug -> 24 hourly buckets
const now = new Date();
const startHour = new Date(now);
startHour.setMinutes(0, 0, 0);
startHour.setHours(startHour.getHours() - 23);

const bucketKeys: string[] = [];
for (let i = 0; i < 24; i++) {
const h = new Date(startHour);
h.setHours(h.getHours() + i);
// Format to match ClickHouse's toStartOfHour output: "YYYY-MM-DD HH:MM:SS"
bucketKeys.push(
h.toISOString().slice(0, 13).replace("T", " ") + ":00:00"
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Timezone mismatch between ClickHouse and JavaScript bucket keys.

The bucket keys are generated using toISOString() (UTC), but ClickHouse's toStartOfHour(start_time) returns values in the server's configured timezone. If the ClickHouse server isn't set to UTC, bucket keys won't match and sparkline data will misalign or appear as zeros.

🔧 Proposed fix to enforce UTC in ClickHouse query
       query: `SELECT
           prompt_slug,
-          toStartOfHour(start_time) AS bucket,
+          toStartOfHour(start_time, 'UTC') AS bucket,
           count() AS cnt
         FROM trigger_dev.llm_metrics_v1
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/presenters/v3/PromptPresenter.server.ts` around lines 100 -
141, The ClickHouse bucket timestamps are produced in the server timezone while
bucketKeys are generated in UTC (toISOString), causing mismatches; update the
query to force UTC on the aggregation (e.g., change toStartOfHour(start_time) to
toStartOfHour(toTimeZone(start_time, 'UTC')) or toStartOfHour(start_time,
'UTC')) so the returned bucket field uses UTC, leaving the bucketKeys generation
(h.toISOString().slice(0,13).replace("T"," ") + ":00:00") as-is; modify the
query SQL in the query object where toStartOfHour is used so prompt usage
sparkline rows align with the UTC bucketKeys.

Comment on lines +6 to +24
async promoteVersion(promptId: string, versionId: string, options?: { sourceGuard?: boolean }) {
if (options?.sourceGuard) {
const target = await this._prisma.promptVersion.findUnique({
where: { id: versionId },
});
if (!target) {
throw new ServiceValidationError("Version not found", 404);
}
if (target.source !== "code") {
throw new ServiceValidationError(
"Only code-sourced versions can be promoted. Use the override API instead.",
400
);
}
}

await this.#removeLabel(promptId, "current");
await this.#addLabel(versionId, "current");
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Validate prompt ownership and make relabel operations atomic.

Line 8 / Line 101 load by versionId only, while Line 22-23 / Line 116-117 mutate labels using promptId from input. A mismatched versionId can remove labels from one prompt and apply to another. Also, remove/add should be a single transaction to avoid partial state.

🔧 Proposed fix
 async promoteVersion(promptId: string, versionId: string, options?: { sourceGuard?: boolean }) {
-  if (options?.sourceGuard) {
-    const target = await this._prisma.promptVersion.findUnique({
-      where: { id: versionId },
-    });
-    if (!target) {
-      throw new ServiceValidationError("Version not found", 404);
-    }
-    if (target.source !== "code") {
-      throw new ServiceValidationError(
-        "Only code-sourced versions can be promoted. Use the override API instead.",
-        400
-      );
-    }
-  }
-
-  await this.#removeLabel(promptId, "current");
-  await this.#addLabel(versionId, "current");
+  await this._prisma.$transaction(async (tx) => {
+    const target = await tx.promptVersion.findUnique({ where: { id: versionId } });
+    if (!target) throw new ServiceValidationError("Version not found", 404);
+    if (target.promptId !== promptId) {
+      throw new ServiceValidationError("Version does not belong to prompt", 400);
+    }
+    if (options?.sourceGuard && target.source !== "code") {
+      throw new ServiceValidationError(
+        "Only code-sourced versions can be promoted. Use the override API instead.",
+        400
+      );
+    }
+
+    await tx.$executeRaw`
+      UPDATE "prompt_versions"
+      SET "labels" = array_remove("labels", 'current')
+      WHERE "promptId" = ${promptId} AND 'current' = ANY("labels")
+    `;
+    await tx.$executeRaw`
+      UPDATE "prompt_versions"
+      SET "labels" = array_append("labels", 'current')
+      WHERE "id" = ${versionId} AND NOT ('current' = ANY("labels"))
+    `;
+  });
 }

Also applies to: 100-118

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/v3/services/promptService.server.ts` around lines 6 - 24,
promoteVersion currently loads the version by versionId but then mutates labels
by promptId, which can relabel the wrong prompt and leave partial state; modify
promoteVersion to first load the PromptVersion via
this._prisma.promptVersion.findUnique (or include promptId) and verify
target.promptId === promptId (throw ServiceValidationError if not), then perform
the relabeling inside a single database transaction (use
this._prisma.$transaction) so `#removeLabel` and `#addLabel` are executed atomically
(or replace with a single transactional update that clears "current" on the old
prompt and sets "current" on the target version) to avoid partial state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant