Skip to content

feat(webapp,sdk): in-dashboard AI agent#4018

Draft
ericallam wants to merge 27 commits into
mainfrom
feat/dashboard-agent-scaffold
Draft

feat(webapp,sdk): in-dashboard AI agent#4018
ericallam wants to merge 27 commits into
mainfrom
feat/dashboard-agent-scaffold

Conversation

@ericallam

Copy link
Copy Markdown
Member

Summary

Adds an in-dashboard AI agent: a chat panel, reachable from any environment
page, that answers questions about your runs, errors, tasks, and analytics,
diagnoses why a run failed, charts your data, reads your connected repo's
source, and answers product and how-to questions. It is gated behind the
hasDashboardAgentAccess feature flag (global or per-org, default off), so
this PR ships disabled: the launcher is hidden unless the flag is enabled.

Design

The agent runs as a standalone chat.agent Trigger task in its own internal
package, with no access to the webapp database, Prisma, or ClickHouse. It reads
the user's data over the public API, acting as the user via a short-lived
delegated user-actor token minted server-side each turn (never in the browser),
building on #3997. The
error and analytics tools use #4005
and the TRQL query API.

The first turn of a new chat streams from a warm webapp route (Head Start) while
the durable agent boots in parallel. Structured answers (a run-failure diagnosis
card, a live chart) render through a small typed view catalog rather than
arbitrary markup. A knowledge lane forwards product and how-to questions to the
support assistant.

Conversation history lives in a separate Drizzle-backed store on its own
Postgres schema, kept as a display read-model so it can never corrupt the
agent's model context.

The SDK changes add an apiClient option to chat.createStartSessionAction and
chat.headStart, and keep the Head Start tool-approval tail intact across a
custom prepareMessages hook so prompt caching and Head Start compose.

ericallam added 26 commits June 22, 2026 17:49
Range-scoped to ^18 so packages on react 19 (rsc) stay untouched.
Collapses the duplicate react copies that otherwise break hook-based
SDK components with an invalid hook call.
When set, the returned action runs under the given baseURL and access
token (via apiClientManager.runWithConfig) instead of the ambient SDK
config, so one server can start chat sessions in a different runtime
environment.
Adds an "Ask the agent" side panel on every environment page, backed by
a chat.agent task that runs as its own internal Trigger project with no
main-database or ClickHouse access. Conversations persist to a dedicated
Postgres schema via a small Drizzle store; the panel restores the last
open chat and resumes streaming without replay.

No tools yet: the agent answers from the model only. Tools, delegated
auth, and the data lane land in follow-ups.
Adds hasDashboardAgentAccess to the flag catalog, off by default via
DASHBOARD_AGENT_ENABLED. The panel renders only when the flag is on for
the org or the viewer is staff, and the resource route enforces the same
check so a non-flagged user cannot start sessions by calling it directly.
Controllable globally and per-org from the admin flags UI.
…+ caching

Drive the agent and title models from dashboard-managed prompts resolved
through an Anthropic provider registry (agent claude-sonnet-4-6, title
claude-haiku-4-5). Generate the chat title in the background after the
first turn with the cheaper model so it never blocks the response, writing
it only while the chat still has the default title. Add Anthropic prompt
caching: an ephemeral breakpoint on the system block plus a rolling
breakpoint on the last message.
The deploy-time prompt dedup hashed only the prompt text, so changing a
code prompt's model or config was silently skipped and the previous
version kept serving. Include model and config in the version-definition
hash so those changes create a new version.
Open the agent inside a ResizablePanelGroup (content + handle + agent
panel) using the shared Resizable primitive, so the panel drag-resizes
between 320 and 720px with the standard handle and keyboard support.
autosaveId persists the width across opens and reloads.
The project worker-by-tag endpoint now accepts a delegated user-actor
token the same way it accepts a personal access token, so a first-party
caller can list a project's deployed tasks on the user's behalf. It stays
identity-scoped, with no change to what the user can already do.
A caret range resolved a newer ai build than the SDK uses, pulling a
second copy of the AI SDK tool types and breaking type-checking against
the chat agent's tool set. Pinning the exact version dedupes them.
The dashboard agent can now read your projects, environments, runs, and
deployed tasks by calling the public API as you. Each turn mints a
short-lived, read-only delegated token on the server and hands it to the
agent through a same-origin proxy on the message-send path, so the token
is never exposed to the browser. The response stream stays pointed
directly at the realtime host. Tools: list_projects, list_environments,
get_run, list_tasks.
The dashboard agent can now list recent runs in the current environment
(filterable by status, task, and a time window of up to 30 days) and
fetch a run's execution trace to explain why it failed, retried, or was
slow.
chat.headStart now accepts an apiClient (base URL + access token), so the
head-start route can create the session and trigger the agent run against a
different project or environment than the warm server's ambient config.
Mirrors chat.createStartSessionAction; the run callback's LLM keys are
unaffected.
Pins ai (^6) and @ai-sdk/provider-utils (^4) via pnpm overrides so the AI SDK
tool types resolve to one instance across the app, the SDK, and internal
packages. Two provider-utils copies previously broke type-checking a shared
tool set across package boundaries. Also adds @ai-sdk/anthropic to the webapp
for the agent's first-turn route.
The first turn of a new dashboard agent chat now streams step 1 from the
webapp while the agent run boots in parallel, cutting first-token latency.
The warm route runs step 1 with schema-only tools (the tool execute fns stay
in the agent, never the webapp bundle) and mints a fresh read-only delegated
token server-side, injected into the run so the first turn's tool calls are
authed as the user without the token ever reaching the browser. Falls back to
the normal path when no Anthropic key is configured.
The dashboard agent can now read error groups as the signed-in user: list_errors lists distinct errors by fingerprint with occurrence counts and status, get_error returns the full detail for one, and list_runs can filter to the runs behind an error group. All read-only and scoped to the current environment.
…eMessages

A Head Start handover hands the first turn's pending tool call to the agent as a tool-approval round whose trailing tool message must reach the model untouched for the call to execute. A prepareMessages hook that rewrote the last message (for example the recommended prompt-caching breakpoint) dropped it, so the turn failed with "tool_use ids were found without tool_result". The agent now preserves that approval tail across prepareMessages, so caching and Head Start compose cleanly.
Drive the agent through real turns offline with mockChatAgent and MockLanguageModelV3: text streaming, tool execution, the prompt-cache breakpoint, the Head Start handover resume, and the read tools failing closed without a delegated token. The agent's datastore and model are now injectable via locals so the tests need no database or provider.
…answer quality

Vitest evals that exercise the agent against a real model with fixture
tools: a tool-selection set scored on expected tool choices, plus an
LLM-as-judge case for answer quality. Runs behind its own config so it
stays out of the unit test run, and *.eval.ts files are kept out of the
task index.
After each turn the agent enqueues a decoupled, idempotency-keyed task
that judges the turn (grounding, whether the question was answered,
intent, outcome) and flags product signal (capability gaps, docs gaps,
support opportunities, feature requests), then writes one row. The judge
runs out of band so it never blocks or bills the agent run. Adds the
chat_turn_evals table and migration to @internal/dashboard-agent-db.
…mode

When a project has a connected GitHub repo, the in-dashboard agent can now read
its source to ground answers. It pulls the repo at the tracked commit onto its
own filesystem and exposes read-only list, read, and grep tools, so it can
explain a run or error against the actual task code, citing file and line.

The webapp resolves a short-lived signed archive URL server-side and injects it
per turn, so the GitHub token never reaches the agent. With no connected repo
the agent stays in its usual assistant mode.
…deployed from

The agent code tools now take an optional runId. When investigating a specific
run, the agent reads the exact source that run version was deployed from instead
of the latest commit, so the explanation matches the code that actually ran.

A new endpoint maps the run to its deployment commit and resolves a signed
archive URL for it server-side (the GitHub token stays on the server); the agent
downloads and reads that commit. Runs with no deployed version fall back to the
tracked branch head.
The dashboard agent now answers "why did this run fail?" with a structured
failure card (summary, category, likely cause, confidence, evidence, impact,
next steps, and action buttons) instead of plain prose.

The card is the first block in a small view catalog: the agent emits a
render_view tool call constrained to a fixed set of blocks, and the dashboard
renders it through a component registry. Only known block types render, so the
agent can never produce arbitrary markup. New blocks plug in by adding a schema
member plus a registry entry.
…iew block

The dashboard agent can now answer analytics questions with data and charts.
Two read-only query tools let it discover the TRQL schema and run queries
against the current environment, and a new "chart" block in the view catalog
renders a line or bar chart.

The chart block carries the TRQL query rather than rows: the panel runs it
through the dashboard's existing query execution and chart components, so the
chart is live and matches the Query page. Queries run as the user over the
public query API (read:query), so the agent still reaches no data directly.
… questions

The dashboard agent can now answer "how does Trigger.dev work?" questions (docs,
concepts, features, how-tos) by asking the Trigger.dev support assistant, instead
of guessing or limiting itself to the user's own data.

The ask_support tool forwards the question to a service-to-service ask endpoint
(the support assistant composes the answer) and returns it. It carries no user
data and uses a shared secret server-side only, so nothing reaches the browser.
AgentChat's .in/append and .out SSE calls built their headers by hand and
omitted x-trigger-branch, so a chat.agent deployed to a preview branch returned
401 "x-trigger-branch header required for preview env" the moment a message was
appended. sessions.start already sends the branch via the API client; these two
raw fetches now do too, from the same apiClientManager.branchName.
Resolve dashboard agent access in the env layout loader (global env override,
then admins, then the global or per-org feature flag, default off) and pass it
to the launcher. The button previously read only the per-org flag on the
client, so enabling the agent globally granted server access but left the
button hidden for non-admins. The launcher now matches the server check.
@changeset-bot

changeset-bot Bot commented Jun 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8211f09

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 28 packages
Name Type
@trigger.dev/sdk Patch
@trigger.dev/python Patch
@internal/dashboard-agent Patch
@internal/sdk-compat-tests Patch
@trigger.dev/build Patch
@trigger.dev/core Patch
@trigger.dev/plugins Patch
@trigger.dev/react-hooks Patch
@trigger.dev/redis-worker Patch
@trigger.dev/rsc Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/rbac Patch
@trigger.dev/sso Patch
trigger.dev Patch
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@internal/testcontainers Patch
@internal/tracing Patch
@internal/tsql Patch
@internal/zod-worker Patch

Not sure what this means? Click here to learn what changesets are.

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

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR introduces a new in-dashboard AI chat agent panel feature (@internal/dashboard-agent) and a companion datastore package (@internal/dashboard-agent-db). The agent is a Trigger.dev chat task using Anthropic, with read-only tools for Trigger.dev API data, a TRQL analytics tool, a support-forwarding tool, a render_view generative-UI tool, and optional GitHub repo code-reading tools when a connected repository exists. A Drizzle/Postgres schema tracks chat history, session resume state, and per-turn LLM-judge evaluation rows. The webapp gains new resource routes for session management, a same-origin proxy that injects delegated user-actor tokens into message turns, and a head-start route. React components add a resizable panel with chat/history views, a view-catalog renderer for diagnosis and chart blocks, and access gated by a new hasDashboardAgentAccess feature flag. Three SDK patches are also included: x-trigger-branch header propagation for preview environments, preservation of the tool-approval-response tail through prepareMessages during Head Start handovers, and a new apiClient option on chat.headStart and chat.createStartSessionAction.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(webapp,sdk): in-dashboard AI agent' accurately summarizes the main feature addition across the webapp and SDK packages.
Description check ✅ Passed The description provides comprehensive context: summary of the feature, design rationale, technical approach, data storage, and SDK changes. However, the PR checklist is not explicitly completed and testing/changelog sections from the template are not filled in.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 feat/dashboard-agent-scaffold

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 13

🧹 Nitpick comments (3)
internal-packages/dashboard-agent-db/src/client.ts (1)

15-25: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Prefer type over interface for data shapes.

CreateDashboardAgentDbOptions is a pure data shape (no methods), so per coding guidelines it should use type instead of interface.

♻️ Suggested change
-export interface CreateDashboardAgentDbOptions {
+export type CreateDashboardAgentDbOptions = {
   /**
    * Max client-side pool size. Keep small — the agent runs in many short-lived
    * task containers and PlanetScale's pooler does the real connection pooling.
    */
   max?: number;
   /** Idle timeout (seconds) so suspended agent runs release connections. */
   idleTimeoutSeconds?: number;
   /** Connection timeout (seconds). */
   connectTimeoutSeconds?: number;
-}
+};

Source: Coding guidelines

internal-packages/dashboard-agent-db/src/queries.ts (1)

14-22: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Prefer type over interface for data shapes.

ChatListItem is a pure data shape, so per coding guidelines it should use type.

♻️ Suggested change
-export interface ChatListItem {
+export type ChatListItem = {
   id: string;
   title: string;
   pinnedAt: Date | null;
   lastMessageAt: Date | null;
   createdAt: Date;
   updatedAt: Date;
   metadata: Record<string, unknown>;
-}
+};

Source: Coding guidelines

.claude/skills/drizzle/SKILL.md (1)

47-60: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Keep schema and query snippets consistent.

Line 114 orders by chats.pinnedAt, but the schema snippet in Lines 47-60 doesn’t define pinnedAt, so the example set is internally inconsistent.

Suggested fix
 export const chats = dashboardAgentSchema.table(
   "chats",
   {
@@
     messages: jsonb("messages").$type<unknown[]>().notNull().default([]),
     metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
+    pinnedAt: timestamp("pinned_at", { withTimezone: true }),
     deletedAt: timestamp("deleted_at", { withTimezone: true }), // soft delete
     lastMessageAt: timestamp("last_message_at", { withTimezone: true }),

Also applies to: 114-114


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a62f9d56-f2aa-4d7d-a8a9-9c957c74d2bf

📥 Commits

Reviewing files that changed from the base of the PR and between bb92935 and 0890055.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (73)
  • .changeset/chat-agent-preview-branch.md
  • .changeset/chat-head-start-prepare-messages.md
  • .changeset/chat-headstart-api-client.md
  • .changeset/chat-start-session-api-client.md
  • .claude/skills/drizzle/SKILL.md
  • .gitignore
  • apps/webapp/app/components/dashboard-agent/AgentChart.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentContextBanner.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx
  • apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx
  • apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx
  • apps/webapp/app/components/dashboard-agent/view-catalog.tsx
  • apps/webapp/app/env.server.ts
  • apps/webapp/app/hooks/useApiOrigin.ts
  • apps/webapp/app/presenters/OrganizationsPresenter.server.ts
  • apps/webapp/app/root.tsx
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx
  • apps/webapp/app/routes/api.v1.projects.$projectRef.$env.repo.snapshot.ts
  • apps/webapp/app/routes/api.v1.projects.$projectRef.$env.workers.$tagName.ts
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.headstart.ts
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts
  • apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts
  • apps/webapp/app/routes/storybook.agent-ui/route.tsx
  • apps/webapp/app/routes/storybook/route.tsx
  • apps/webapp/app/services/dashboardAgent.server.ts
  • apps/webapp/app/services/dashboardAgentDb.server.ts
  • apps/webapp/app/v3/canAccessDashboardAgent.server.ts
  • apps/webapp/app/v3/featureFlags.ts
  • apps/webapp/app/v3/services/createBackgroundWorker.server.ts
  • apps/webapp/package.json
  • docs/ai-chat/fast-starts.mdx
  • docs/ai-chat/prompt-caching.mdx
  • internal-packages/dashboard-agent-db/README.md
  • internal-packages/dashboard-agent-db/drizzle.config.ts
  • internal-packages/dashboard-agent-db/drizzle/0000_magenta_lilandra.sql
  • internal-packages/dashboard-agent-db/drizzle/0001_slimy_living_tribunal.sql
  • internal-packages/dashboard-agent-db/drizzle/meta/0000_snapshot.json
  • internal-packages/dashboard-agent-db/drizzle/meta/0001_snapshot.json
  • internal-packages/dashboard-agent-db/drizzle/meta/_journal.json
  • internal-packages/dashboard-agent-db/package.json
  • internal-packages/dashboard-agent-db/src/client.ts
  • internal-packages/dashboard-agent-db/src/index.ts
  • internal-packages/dashboard-agent-db/src/queries.ts
  • internal-packages/dashboard-agent-db/src/schema.ts
  • internal-packages/dashboard-agent-db/tsconfig.json
  • internal-packages/dashboard-agent/.gitignore
  • internal-packages/dashboard-agent/README.md
  • internal-packages/dashboard-agent/eval-setup.ts
  • internal-packages/dashboard-agent/package.json
  • internal-packages/dashboard-agent/src/dashboard-agent.eval.ts
  • internal-packages/dashboard-agent/src/dashboard-agent.test.ts
  • internal-packages/dashboard-agent/src/dashboard-agent.ts
  • internal-packages/dashboard-agent/src/eval-turn.ts
  • internal-packages/dashboard-agent/src/index.ts
  • internal-packages/dashboard-agent/src/prompts.ts
  • internal-packages/dashboard-agent/src/repo-tools.test.ts
  • internal-packages/dashboard-agent/src/repo-tools.ts
  • internal-packages/dashboard-agent/src/tool-schemas.ts
  • internal-packages/dashboard-agent/src/tools.ts
  • internal-packages/dashboard-agent/trigger.config.ts
  • internal-packages/dashboard-agent/tsconfig.json
  • internal-packages/dashboard-agent/vitest.config.ts
  • internal-packages/dashboard-agent/vitest.eval.config.ts
  • package.json
  • packages/trigger-sdk/src/v3/ai.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/trigger-sdk/src/v3/chat-server.ts


## Package layout

```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language identifier to the fenced block.

Line 24 opens a fenced block without a language, which triggers markdownlint MD040.

Suggested fix
-```
+```text
 internal-packages/dashboard-agent-db/
   drizzle.config.ts      # drizzle-kit config (schema path, out dir, schemaFilter)
   drizzle/               # generated migrations (committed)
   src/
@@
-```
+```
📝 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
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 24-24: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

Source: Linters/SAST tools

Comment on lines +147 to +151
const prevStatus = useRef(status);
useEffect(() => {
if (prevStatus.current === "streaming" && status === "ready") onTurnSettled();
prevStatus.current = status;
}, [status, onTurnSettled]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Refresh history when a turn ends in error as well as ready.

The current status gate only syncs after streaming -> ready; failed turns skip onTurnSettled, so history can stay stale.

Suggested fix
   const prevStatus = useRef(status);
   useEffect(() => {
-    if (prevStatus.current === "streaming" && status === "ready") onTurnSettled();
+    const wasInFlight =
+      prevStatus.current === "streaming" || prevStatus.current === "submitted";
+    const nowSettled = status === "ready" || status === "error";
+    if (wasInFlight && nowSettled) onTurnSettled();
     prevStatus.current = status;
   }, [status, onTurnSettled]);
📝 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 prevStatus = useRef(status);
useEffect(() => {
if (prevStatus.current === "streaming" && status === "ready") onTurnSettled();
prevStatus.current = status;
}, [status, onTurnSettled]);
const prevStatus = useRef(status);
useEffect(() => {
const wasInFlight =
prevStatus.current === "streaming" || prevStatus.current === "submitted";
const nowSettled = status === "ready" || status === "error";
if (wasInFlight && nowSettled) onTurnSettled();
prevStatus.current = status;
}, [status, onTurnSettled]);

Comment on lines +29 to +33
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Prevent Enter-submit while IME composition is active.

Pressing Enter during composition can send unfinished input for CJK users.

Suggested fix
             onKeyDown={(e) => {
-              if (e.key === "Enter" && !e.shiftKey) {
+              if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
                 e.preventDefault();
                 onSubmit();
               }
             }}

Comment on lines +65 to +70
<button
type="button"
onClick={() => onDelete(chat.id)}
aria-label="Delete chat"
className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity hover:text-error group-hover:opacity-100"
>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the delete action visible on keyboard focus.

The delete button is hover-only; keyboard users can focus an effectively hidden control.

Suggested fix
                   <button
                     type="button"
                     onClick={() => onDelete(chat.id)}
                     aria-label="Delete chat"
-                    className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity hover:text-error group-hover:opacity-100"
+                    className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity hover:text-error group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 focus-custom"
                   >
                     <TrashIcon className="size-3.5" />
                   </button>
📝 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
<button
type="button"
onClick={() => onDelete(chat.id)}
aria-label="Delete chat"
className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity hover:text-error group-hover:opacity-100"
>
<button
type="button"
onClick={() => onDelete(chat.id)}
aria-label="Delete chat"
className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity hover:text-error group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 focus-custom"
>

Comment on lines +86 to +120
const openChat = useCallback(
async (id: string, opts?: { fetchExisting?: boolean }) => {
setView("chat");
if (!opts?.fetchExisting) {
setActive({ chatId: id, messages: [], session: null });
return;
}
setLoading(true);
try {
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(id)}`);
const data = res.ok
? ((await res.json()) as {
messages?: UIMessage[];
session?: { publicAccessToken: string; lastEventId: string | null } | null;
})
: { messages: [], session: null };
if (data.messages && data.messages.length > 0) {
setActive({
chatId: id,
messages: data.messages,
session: data.session?.publicAccessToken
? {
publicAccessToken: data.session.publicAccessToken,
lastEventId: data.session.lastEventId ?? undefined,
}
: null,
});
} else {
setActive({ chatId: generateFriendlyId("chat"), messages: [], session: null });
}
} finally {
setLoading(false);
}
},
[actionPath]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard chat-open requests against stale async responses.

Rapid chat switches can resolve out of order and let an older response overwrite the newest selection. Add a request sequence (or AbortController) gate before setting active/loading.

Suggested fix
+  const openChatRequestSeq = useRef(0);
+
   const openChat = useCallback(
     async (id: string, opts?: { fetchExisting?: boolean }) => {
       setView("chat");
       if (!opts?.fetchExisting) {
         setActive({ chatId: id, messages: [], session: null });
         return;
       }
+      const seq = ++openChatRequestSeq.current;
       setLoading(true);
       try {
         const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(id)}`);
         const data = res.ok
           ? ((await res.json()) as {
               messages?: UIMessage[];
               session?: { publicAccessToken: string; lastEventId: string | null } | null;
             })
           : { messages: [], session: null };
+        if (seq !== openChatRequestSeq.current) return;
         if (data.messages && data.messages.length > 0) {
           setActive({
             chatId: id,
             messages: data.messages,
             session: data.session?.publicAccessToken
               ? {
                   publicAccessToken: data.session.publicAccessToken,
                   lastEventId: data.session.lastEventId ?? undefined,
                 }
               : null,
           });
         } else {
           setActive({ chatId: generateFriendlyId("chat"), messages: [], session: null });
         }
       } finally {
-        setLoading(false);
+        if (seq === openChatRequestSeq.current) {
+          setLoading(false);
+        }
       }
     },
     [actionPath]
   );
📝 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 openChat = useCallback(
async (id: string, opts?: { fetchExisting?: boolean }) => {
setView("chat");
if (!opts?.fetchExisting) {
setActive({ chatId: id, messages: [], session: null });
return;
}
setLoading(true);
try {
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(id)}`);
const data = res.ok
? ((await res.json()) as {
messages?: UIMessage[];
session?: { publicAccessToken: string; lastEventId: string | null } | null;
})
: { messages: [], session: null };
if (data.messages && data.messages.length > 0) {
setActive({
chatId: id,
messages: data.messages,
session: data.session?.publicAccessToken
? {
publicAccessToken: data.session.publicAccessToken,
lastEventId: data.session.lastEventId ?? undefined,
}
: null,
});
} else {
setActive({ chatId: generateFriendlyId("chat"), messages: [], session: null });
}
} finally {
setLoading(false);
}
},
[actionPath]
const openChatRequestSeq = useRef(0);
const openChat = useCallback(
async (id: string, opts?: { fetchExisting?: boolean }) => {
setView("chat");
if (!opts?.fetchExisting) {
setActive({ chatId: id, messages: [], session: null });
return;
}
const seq = ++openChatRequestSeq.current;
setLoading(true);
try {
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(id)}`);
const data = res.ok
? ((await res.json()) as {
messages?: UIMessage[];
session?: { publicAccessToken: string; lastEventId: string | null } | null;
})
: { messages: [], session: null };
if (seq !== openChatRequestSeq.current) return;
if (data.messages && data.messages.length > 0) {
setActive({
chatId: id,
messages: data.messages,
session: data.session?.publicAccessToken
? {
publicAccessToken: data.session.publicAccessToken,
lastEventId: data.session.lastEventId ?? undefined,
}
: null,
});
} else {
setActive({ chatId: generateFriendlyId("chat"), messages: [], session: null });
}
} finally {
if (seq === openChatRequestSeq.current) {
setLoading(false);
}
}
},
[actionPath]
);

Comment on lines +110 to +115
case "token": {
if (!isDashboardAgentConfigured()) {
return json({ error: "The dashboard agent is not configured." }, { status: 501 });
}
return json({ token: await mintDashboardAgentToken(chatId) });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Authorize chatId before minting a session token.

The token intent mints a read/write session token from a client-supplied chatId without verifying that the chat belongs to the authenticated user. A caller with dashboard-agent access could request a token for another session ID if they obtain it.

🔐 Suggested fix
     case "token": {
       if (!isDashboardAgentConfigured()) {
         return json({ error: "The dashboard agent is not configured." }, { status: 501 });
       }
+      const session = await getSession(dashboardAgentDb, { chatId, userId });
+      if (!session) {
+        return json({ error: "Chat not found" }, { status: 404 });
+      }
       return json({ token: await mintDashboardAgentToken(chatId) });
     }

Comment on lines +94 to +95
const repoSnapshotCache = new Map<string, { snapshot: DashboardAgentRepoSnapshot; expiresAt: number }>();
const REPO_SNAPSHOT_TTL_MS = 60_000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bound and prune repoSnapshotCache to prevent process-lifetime memory growth.

Entries expire logically, but expired keys are never deleted, and key cardinality grows with unique projectId:ref values (especially pinned SHAs). Over time this can leak memory in long-lived web processes.

💡 Suggested fix
 const repoSnapshotCache = new Map<string, { snapshot: DashboardAgentRepoSnapshot; expiresAt: number }>();
 const REPO_SNAPSHOT_TTL_MS = 60_000;
+const REPO_SNAPSHOT_MAX_ENTRIES = 1_000;
+
+function pruneRepoSnapshotCache(now = Date.now()) {
+  for (const [key, value] of repoSnapshotCache) {
+    if (value.expiresAt <= now) repoSnapshotCache.delete(key);
+  }
+  if (repoSnapshotCache.size <= REPO_SNAPSHOT_MAX_ENTRIES) return;
+  const overflow = repoSnapshotCache.size - REPO_SNAPSHOT_MAX_ENTRIES;
+  let removed = 0;
+  for (const key of repoSnapshotCache.keys()) {
+    repoSnapshotCache.delete(key);
+    removed++;
+    if (removed >= overflow) break;
+  }
+}
@@
   const cached = repoSnapshotCache.get(cacheKey);
-  if (cached && cached.expiresAt > Date.now()) return cached.snapshot;
+  if (cached && cached.expiresAt > Date.now()) return cached.snapshot;
+  if (cached) repoSnapshotCache.delete(cacheKey);
@@
     const snapshot: DashboardAgentRepoSnapshot = { tarballUrl, owner, repo, sha, defaultBranch };
+    pruneRepoSnapshotCache();
     repoSnapshotCache.set(cacheKey, { snapshot, expiresAt: Date.now() + REPO_SNAPSHOT_TTL_MS });
     return snapshot;

Also applies to: 168-168

Comment on lines +49 to +52
export function workdirFor(snapshot: RepoSnapshot): string {
const safe = `${snapshot.owner}-${snapshot.repo}-${snapshot.sha}`.replace(/[^A-Za-z0-9._-]/g, "_");
return join(tmpdir(), "dashboard-agent-repo", safe);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Make workspace keys collision-resistant to avoid cross-repo workspace reuse.

workdirFor() builds a reversible-ish string with owner-repo-sha; different (owner, repo) pairs can collide (e.g. hyphen placement), and if sha also matches, one repo can reuse another repo’s extracted workspace. That can leak source between tenants/projects.

Suggested fix
+import { createHash } from "node:crypto";
 import { execFile } from "node:child_process";
@@
 export function workdirFor(snapshot: RepoSnapshot): string {
-  const safe = `${snapshot.owner}-${snapshot.repo}-${snapshot.sha}`.replace(/[^A-Za-z0-9._-]/g, "_");
-  return join(tmpdir(), "dashboard-agent-repo", safe);
+  const key = createHash("sha256")
+    .update(`${snapshot.owner}\0${snapshot.repo}\0${snapshot.sha}`)
+    .digest("hex");
+  return join(tmpdir(), "dashboard-agent-repo", key);
 }
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFile } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

Comment on lines +110 to +116
// Resolve a tool-supplied path inside the workspace, rejecting any `..` escape.
function safeResolve(workdir: string, input: string): string | null {
const cleaned = input.replace(/^\/+/, "");
if (isAbsolute(cleaned)) return null;
const target = resolve(workdir, cleaned);
if (target !== workdir && !target.startsWith(workdir + sep)) return null;
return target;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Block symlink-based escapes when resolving repo paths.

The current guard is lexical only. A path inside the workspace can still be a symlink to / (or another external location), and readFile() / rg will follow it. That lets repository-controlled content escape the workspace boundary.

Suggested fix
-import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
@@
 function safeResolve(workdir: string, input: string): string | null {
@@
   return target;
 }
+
+function isInside(root: string, target: string): boolean {
+  return target === root || target.startsWith(root + sep);
+}
@@
-        const sub = path ? safeResolve(workdir, path) : workdir;
+        const sub = path ? safeResolve(workdir, path) : workdir;
         if (sub === null) return { error: "Path escapes the repository root." };
+        const realSub = await realpath(sub).catch(() => null);
+        const realRoot = await realpath(workdir).catch(() => null);
+        if (!realSub || !realRoot || !isInside(realRoot, realSub)) {
+          return { error: "Path escapes the repository root." };
+        }
         try {
-          const { stdout } = await execFileAsync("rg", args, { cwd: sub, maxBuffer: 16 * 1024 * 1024 });
-          const files = stdout.split("\n").filter(Boolean).map((f) => relative(workdir, resolve(sub, f)));
+          const { stdout } = await execFileAsync("rg", args, { cwd: realSub, maxBuffer: 16 * 1024 * 1024 });
+          const files = stdout.split("\n").filter(Boolean).map((f) => relative(workdir, resolve(realSub, f)));
@@
         const target = safeResolve(workdir, path);
         if (target === null) return { error: "Path escapes the repository root." };
+        const realTarget = await realpath(target).catch(() => null);
+        const realRoot = await realpath(workdir).catch(() => null);
+        if (!realTarget || !realRoot || !isInside(realRoot, realTarget)) {
+          return { error: "Path escapes the repository root." };
+        }
         let content: string;
@@
-          const buf = await readFile(target);
+          const buf = await readFile(realTarget);

Also applies to: 178-183, 198-205

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFile } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

Comment on lines +3032 to +3035
const withoutApprovalTail = prepared.filter(
(m) => m !== originalTail && !hasToolApprovalResponse(m)
);
return [...withoutApprovalTail, originalTail];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid stripping historical tool-approval responses from the prepared history.

Line 3032 filters out all messages matching tool-approval-response, not just a rewritten trailing tail. On resume turns with older approval rounds in history, those rows get dropped and model context is mutated unexpectedly.

Proposed fix
-  const withoutApprovalTail = prepared.filter(
-    (m) => m !== originalTail && !hasToolApprovalResponse(m)
-  );
-  return [...withoutApprovalTail, originalTail];
+  const withoutMovedOriginal = prepared.filter((m) => m !== originalTail);
+  // Only normalize trailing approval rows introduced by the hook rewrite.
+  while (hasToolApprovalResponse(withoutMovedOriginal[withoutMovedOriginal.length - 1])) {
+    withoutMovedOriginal.pop();
+  }
+  return [...withoutMovedOriginal, originalTail];

Self-hosted containers now apply the trigger_dashboard_agent schema migrations
on startup, alongside the Prisma and ClickHouse migrations, via a small runtime
migrator (drizzle-orm over the committed SQL, no drizzle-kit in the image). A new
SKIP_DASHBOARD_AGENT_MIGRATIONS flag lets deployments that apply migrations out of
band opt out.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fdda1616-3516-4bcb-b09c-c0d2588397f2

📥 Commits

Reviewing files that changed from the base of the PR and between 0890055 and 8211f09.

📒 Files selected for processing (3)
  • docker/scripts/entrypoint.sh
  • internal-packages/dashboard-agent-db/migrate.mjs
  • internal-packages/dashboard-agent-db/package.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal-packages/dashboard-agent-db/package.json
📜 Review details
⏰ Context from checks skipped due to timeout. (38)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (10, 12)
  • GitHub Check: sdk-compat / Node.js 20.20 (ubuntu-latest)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (7, 12)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (1, 12)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (12, 12)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (9, 12)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (9, 10)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (3, 12)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (6, 10)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (5, 12)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (11, 12)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (2, 12)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (8, 12)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (3, 10)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (10, 10)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (8, 10)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (4, 12)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (1, 10)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (5, 10)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (6, 12)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (7, 10)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - npm)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (4, 10)
  • GitHub Check: sdk-compat / Node.js 22.12 (ubuntu-latest)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - pnpm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - pnpm)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (2, 10)
  • GitHub Check: sdk-compat / Cloudflare Workers
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - npm)
  • GitHub Check: typecheck / typecheck
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp
  • GitHub Check: sdk-compat / Bun Runtime
  • GitHub Check: packages / 🧪 Unit Tests: Packages (3, 3)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (1, 3)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (2, 3)
  • GitHub Check: sdk-compat / Deno Runtime
  • GitHub Check: 🛡️ E2E Auth Tests (full)
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (1)
internal-packages/dashboard-agent-db/migrate.mjs (1)

1-57: LGTM!

Comment on lines +16 to +23
if [ "$SKIP_DASHBOARD_AGENT_MIGRATIONS" != "1" ]; then
echo "Running dashboard agent migrations"
pnpm --filter @internal/dashboard-agent-db db:migrate:deploy
echo "Dashboard agent migrations done"
else
echo "SKIP_DASHBOARD_AGENT_MIGRATIONS=1, skipping dashboard agent migrations."
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wait for the dashboard-agent DB endpoint before running its migrations.

If DASHBOARD_AGENT_DATABASE_URL points to a different host than the one checked at Line 4, this block can race DB readiness and fail container startup.

Suggested fix
+if [ -n "$DASHBOARD_AGENT_DATABASE_HOST" ]; then
+  scripts/wait-for-it.sh "${DASHBOARD_AGENT_DATABASE_HOST}" -- echo "dashboard-agent database is up"
+fi
+
 if [ "$SKIP_DASHBOARD_AGENT_MIGRATIONS" != "1" ]; then
   echo "Running dashboard agent migrations"
   pnpm --filter `@internal/dashboard-agent-db` db:migrate:deploy
   echo "Dashboard agent migrations done"
 else
   echo "SKIP_DASHBOARD_AGENT_MIGRATIONS=1, skipping dashboard agent migrations."
 fi

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