Skip to content

feat(webapp): dashboard agent — UI - #4529

Open
kathiekiwi wants to merge 10 commits into
feat/dashboard-agent-flowsfrom
feat/dashboard-agent-ui
Open

feat(webapp): dashboard agent — UI#4529
kathiekiwi wants to merge 10 commits into
feat/dashboard-agent-flowsfrom
feat/dashboard-agent-ui

Conversation

@kathiekiwi

@kathiekiwi kathiekiwi commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #4418. Merge that first.

The dashboard agent's UI: the side panel, the marks that tell it which page you're on, and the entry points. #4418 works without this — the system is simply invisible.

What's inside

  • Panel — chat, rich cards, fullscreen, transcript, quota.
  • Suggested prompts — page-aware chips; investigate chips appear only on loader-backed abnormal state.
  • Page markshandle.agentPageContext on 47 routes, ~20 lines each.
  • Entry points — Ask Trigger button, ⌘J, Help & Feedback. The old ⌘I and ?aiHelp= links keep working.

Notes

  • Gated by canAccessDashboardAgent; no behavior change with the flag off.
  • Ask AI stays in the tree, deprecated and unmounted.
  • Local setup and a hands-on walkthrough live in GUIDEBOOK.md, which lands with feat(webapp): dashboard agent — Watch #4525.

The panel, the page-context marks on the pages the agent reads, and the entry points.
@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: bd4d4a0

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

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ec4c5384-0e41-4c16-ba1d-14aa8e20b4bb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 5 potential issues.

Open in Devin Review

Comment thread apps/webapp/app/hooks/useAgentPageContext.ts
Comment on lines 812 to 814
},
});`;

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.

🟡 Scheduled-task example code shown in the dashboard no longer compiles when copied

The example code for a scheduled task now starts with a blank line instead of its import (const SCHEDULED_TASK_CODE at apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx:805-814), while the standard-task example gained an import it doesn't use.

Impact: Users copying the scheduled-task snippet from the empty-state panels get code that fails to compile, and the standard snippet carries a misleading unused import.

Both empty-state snippet pairs are affected

The same swap was made in apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.dashboard/route.tsx:76-86 (STANDARD_EXAMPLE / SCHEDULED_EXAMPLE). In both files the schedules import was moved from the scheduled snippet onto the standard snippet, leaving schedules.task({...}) referenced with no import and task({...}) importing schedules for nothing. This change is unrelated to the dashboard-agent work in the rest of the PR and looks accidental.

(Refers to lines 805-814)

Prompt for agents
Revert the accidental import swap in the empty-state code samples: `STANDARD_TASK_CODE` / `STANDARD_EXAMPLE` should import only `{ task }` from "@trigger.dev/sdk", and `SCHEDULED_TASK_CODE` / `SCHEDULED_EXAMPLE` should start with `import { schedules } from "@trigger.dev/sdk";` again. Affected files: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx and apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.dashboard/route.tsx.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +5 to +8
// Always undefined until billing supplies plan detection, which means no cap.
function useIsFreePlan(): boolean | undefined {
return undefined;
}

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.

🔍 Free-plan message cap is permanently inert

useIsFreePlan() always returns undefined, so resolveMessageQuota always yields { kind: "unlimited" }. That means AgentUpgradeBlock, AgentQuotaNotice, the atMessageCap guard in DashboardAgentChat and the ?quota=1 fetch are all dead paths today. Worth confirming this is intentional scaffolding (the comment says billing hasn't supplied plan detection yet) rather than a wiring omission. Note also that if the cap ever activates mid-stream, the composer — and with it the Stop button — is unmounted while a turn is still streaming.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +128 to +147
<Fragment>
{/* This popover lives in the app layout, above the agent's provider, so it
opens the panel through the open-request bridge rather than context, and
hides when no host is mounted. The host registers the keystroke; this
only shows it. */}
{agentAvailable && (
<div className="flex flex-col gap-1 p-1">
<SideMenuItemButton
icon={AgentIcon}
iconClassName={AGENT_ICON_ACCENT_CLASS}
name={ASK_AGENT_LABEL}
data-action="ask-agent"
trailing={<ShortcutKey shortcut={TOGGLE_PANEL_SHORTCUT} variant="medium" />}
onClick={() => {
setHelpMenuOpen(false);
requestDashboardAgent();
}}
/>
</div>
)}

@devin-ai-integration devin-ai-integration Bot Aug 7, 2026

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.

🔍 Legacy ⌘I now only works inside the environment layout

AskAIRoot (which hosted the ⌘I shortcut app-wide from the sidebar) is removed, and the legacy shortcut is re-registered only in DashboardAgent, which mounts in the environment layout and is gated on hasAccess. So ⌘I is a no-op on org-level pages (billing, settings, project list) and for anyone without agent access — previously it opened Ask AI everywhere. The ?ask=/?aiHelp= deep links have the same scope limitation via useDashboardAgentOpenRequests.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 476 to 480
<PageTitle title="Queues" />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/queue-concurrency")}
>
Queues docs
</LinkButton>
</PageAccessories>
</NavBar>

@devin-ai-integration devin-ai-integration Bot Aug 7, 2026

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.

🔍 Docs buttons removed from ~20 page headers without being mentioned in the PR description

This PR strips the "… docs" LinkButton out of the NavBar/PageAccessories of many unrelated pages (Tasks, Runs, Queues, Deployments, Batches, Alerts, API keys, Environment variables, Branches, Bulk actions, Waitpoints, Sessions, Prompts, Agents, Test, Limits — e.g. apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx:476 and :1685). Nothing is added in their place in those headers — the Ask Trigger launcher is a separate control. The PR description only mentions the agent panel and entry points, so it is worth confirming that removing every in-page documentation shortcut is the intended product change and not collateral from the entry-point refactor.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +343 to +349
// A React handler, not a global hotkey, so Esc stays scoped to the panel.
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
event.preventDefault();
onClose();
}}
>

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.

🟡 Dismissing the chat history menu with the escape key also shuts the whole chat panel

Pressing Escape anywhere inside the chat panel closes the panel (onClose() at apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx:347) even when the key was only meant to dismiss the history menu or the delete-chat confirmation, so the user loses the panel as well.

Impact: Backing out of the chat history list or the delete confirmation unexpectedly closes the entire chat.

Why portal content still reaches the panel's key handler

The history popover (PopoverContent in apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx:82-97) and the delete dialog (apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx:117-150) render through Radix portals, but React synthetic events bubble along the React tree, not the DOM tree — so a keydown inside either overlay still reaches the panel's onKeyDown.

Radix's dismissable layer handles Escape from a document-level listener and does not call preventDefault() on the original event, so the panel's guard event.defaultPrevented is false and the panel closes at the same time the overlay does.

A fix is to ignore Escape when an overlay is open (e.g. track the popover/dialog open state, or stop propagation from the overlay content) rather than relying on defaultPrevented.

Prompt for agents
The dashboard agent panel installs a React onKeyDown handler on its root div that closes the panel on Escape, guarded only by event.defaultPrevented. Overlays rendered inside the panel's React tree — the chat history Popover in DashboardAgentHeader.tsx and the delete-confirmation Dialog in DashboardAgentHistory.tsx — are Radix portals, and React synthetic events bubble along the React tree, so Escape inside those overlays also reaches this handler. Radix dismisses on Escape from a document-level keydown listener without calling preventDefault on the event, so defaultPrevented stays false and the panel closes together with the overlay. Consider making the Escape-to-close conditional on no overlay being open (the header already tracks isHistoryOpen, and the history menu tracks pendingDelete), or have the overlay content stop propagation of Escape keydowns.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 3 new potential issues.

Open in Devin Review

Comment on lines +117 to +121
<DialogContent>
<DialogHeader>Delete this chat?</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
"{pendingDelete?.title}" and everything in it will be deleted. This can't be undone.

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.

🟡 Confirming deletion of a chat is impossible because the confirmation box vanishes

The delete-confirmation box is rendered inside the chat-history dropdown (Dialog at apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx:117-150, which is mounted inside PopoverContent at apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx:82-97), so opening it pulls focus out of the dropdown, the dropdown closes, and the box disappears with it.

Impact: Users can't delete a chat — the confirmation prompt closes itself the moment it appears.

Non-modal Radix Popover dismisses on focus outside, unmounting its subtree

PopoverContent (apps/webapp/app/components/primitives/Popover.tsx:19-39) is a non-modal PopoverPrimitive.Content in a portal. DashboardAgentHistoryMenu renders its confirmation Dialog as part of that content. Radix's DialogContent portals to document.body and auto-focuses, which is a focus-outside event for the popover's dismissable layer; the popover closes, unmounting DashboardAgentHistoryMenu and therefore the dialog, with pendingDelete state destroyed too.

The rest of the codebase already avoids this by rendering the dialog as a sibling of the Popover, not inside PopoverContent — see apps/webapp/app/components/metrics/TitleWidget.tsx:55-90, where the rename dialog sits outside the popover.

Fix by lifting the confirmation dialog (and its pendingDelete state) out of the popover content into DashboardAgentHeader, or by closing the popover first and rendering the dialog at header level.

Prompt for agents
The delete-chat confirmation dialog in DashboardAgentHistoryMenu (apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx) is rendered inside the tree that DashboardAgentHeader mounts into <PopoverContent> (apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx). The Popover primitive is non-modal and dismisses on focus-outside; a Radix Dialog portals to document.body and steals focus when it opens, so the popover closes and unmounts the menu — taking the dialog and its pendingDelete state with it, making deletion unconfirmable. The established pattern in this repo (see apps/webapp/app/components/metrics/TitleWidget.tsx) is to render the dialog as a sibling of <Popover>, outside PopoverContent. Consider lifting pendingDelete state and the Dialog up into DashboardAgentHeader (or into the panel), having the menu row call an onRequestDelete(chat) callback that also closes the popover.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +148 to +154
if (!to) {
return (
<Button variant="primary/small" onClick={() => {}}>
{label}
</Button>
);
}

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.

🟡 Diagnosis card shows a clickable button that does nothing

When the run link can't be built the card still renders a full primary button with an empty click handler (onClick={() => {}} at apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx:150), so the user sees a working-looking action that silently does nothing.

Impact: A prominent "view run" button appears enabled but never does anything when the page lacks run context.

Previously this degraded to plain text

useRunPath returns null when org/project/environment context is absent (apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx:84-91). Before this PR RunActionButton rendered a <span> with the label in that case; it now renders <Button variant="primary/small" onClick={() => {}}>. Either render non-interactive text or a disabled button.

Suggested change
if (!to) {
return (
<Button variant="primary/small" onClick={() => {}}>
{label}
</Button>
);
}
if (!to) {
return (
<Button variant="primary/small" disabled>
{label}
</Button>
);
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +7 to +13

// Registered once, by `DashboardAgent`. The launcher only displays it.
export const TOGGLE_PANEL_SHORTCUT: Shortcut = {
modifiers: ["mod"],
key: "j",
// The composer holds focus while the panel is open, so the key must fire from
// inside a text field.

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.

🔍 ⌘J collides with Chrome's Show Downloads and is not prevented

TOGGLE_PANEL_SHORTCUT binds mod+j. useShortcutKeys (apps/webapp/app/hooks/useShortcutKeys.tsx:44-58) calls useHotkeys without preventDefault, which defaults to false in react-hotkeys-hook. In Chrome, ⌘J (macOS) / Ctrl+J (Windows/Linux) opens the Downloads page, so pressing the new primary agent shortcut may open the panel and a downloads tab. Worth verifying in Chrome and Firefox; if it fires, the hook needs a preventDefault option (or event.preventDefault() inside the action) for this shortcut.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +97 to +102
function useInvestigationWinners(messages: UIMessage[]): Map<string, string> {
const previous = useRef<Map<string, string>>();
const next = useMemo(() => winningInvestigationOccurrences(messages), [messages]);
previous.current = reuseWinners(previous.current, next);
return previous.current;
}

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.

🔍 Investigation-winner recomputation re-parses every report block per streamed token

useInvestigationWinners(stripped) memoizes on messages, which changes on every streamed chunk. winningInvestigationOccurrences walks every part of every message and calls blocksFor, which for each tool-get_report part runs reportBlockFromToolPart — a full reportBlockSchema.safeParse over the whole report view model (apps/webapp/app/components/dashboard-agent/report-block-adapter.ts:57). In a long transcript containing several report cards this means a zod parse of every report VM on every token of an in-flight turn. reuseWinners only stabilises the resulting map identity; it does not avoid the parsing work. Consider memoizing blocksFor/reportBlockFromToolPart per part (e.g. a WeakMap keyed by the part object or by toolCallId).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +243 to +253
// Seeded from the loaded transcript before first render, so history never re-navigates.
const navigatedRef = useRef<Set<string> | null>(null);
if (navigatedRef.current === null) {
navigatedRef.current = new Set();
pendingNavigateIntents(initialMessages, navigatedRef.current);
}
useEffect(() => {
const pending = pendingNavigateIntents(messages, navigatedRef.current!);
const target = pending.at(-1);
if (target) void goTo(target);
}, [messages, goTo]);

@devin-ai-integration devin-ai-integration Bot Aug 7, 2026

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.

🔍 Navigate intents from the model auto-navigate the whole app

Any navigate_to tool output the agent emits is executed automatically: the effect picks the most recent unseen intent and calls goTo, which resolves the trigger:// URI server-side and then navigate()s. There is no user confirmation and no check that the panel is focused, so a mid-stream tool call can pull the user off the page they are reading. The seen set is seeded from the loaded transcript so history doesn't re-fire, which is the right guard, but the live case is unguarded — worth confirming that unconditional navigation is the intended product behaviour.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines 25 to 26
open: boolean;
setOpen: (open: boolean) => void;

@devin-ai-integration devin-ai-integration Bot Aug 7, 2026

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.

🔍 Whether a prompt is sent or merely typed depends on restore state

openWith sends the prompt as a new chat when active is null but only prefills the composer when a chat is restored (DashboardAgentPanel.tsx:283-292). Since restore depends on whether a last-chat entry happens to match the current pathname, clicking the same "Investigate" button can either ask the question immediately or silently drop the text into the composer. The behaviour is documented on openWith, but from the user's side it is non-deterministic; entry points like InvestigateButton probably want the question asked either way.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@110a427

trigger.dev

npm i https://pkg.pr.new/trigger.dev@110a427

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@110a427

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@110a427

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@110a427

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@110a427

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@110a427

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@110a427

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@110a427

commit: 110a427

@devin-ai-integration devin-ai-integration 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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +93 to +97
const promotedDashboardAgentPrompt = hasDashboardAgentAccess
? await getPromotedDashboardAgentPrompt({
orgFeatureFlags: (project.organization.featureFlags as Record<string, unknown>) ?? {},
})
: null;

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.

🔍 Promoted-prompt flag adds an uncached DB round-trip to the environment layout loader

getPromotedDashboardAgentPrompt calls makeFlag(), and flag() in apps/webapp/app/v3/featureFlags.server.ts:28 always issues featureFlag.findFirst before it looks at the caller-supplied overrides. So even when the org already carries the override, every environment-layout load (i.e. essentially every dashboard navigation for users with agent access) pays an extra query, on top of the one canAccessDashboardAgent already makes. The repo provides cachedFlag (featureFlags.server.ts:66) precisely for "global flags read on hot paths … where a database round-trip per request is too expensive"; it rejects overrides, so using it here would need either a global-only read or an override short-circuit inside flag() before the query.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines 177 to 181
: null,
});
} else {
// Nothing stored under this id — drop to a fresh draft.
setActive(null);
}

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.

🔍 Opening a stored chat with an empty transcript silently drops to the draft state

openChat only calls setActive when data.messages.length > 0; otherwise it sets active to null, which renders the "New chat" draft. A chat row that exists but whose transcript hasn't been written yet (a chat created moments ago, or one whose first turn produced nothing) will therefore look like it vanished when selected from the history menu, with no error shown. Now that history is a first-class popover users click through, this failure mode is much easier to hit than it was behind the old history tab.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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