Skip to content

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

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

feat(webapp): dashboard agent — UI#4529
kathiekiwi wants to merge 6 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: 8e0ae47

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: fa0bea75-ff9f-44a3-84f1-40704b46f82e

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 on lines +13 to +22
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const mapper = (match.handle as Handle | undefined)?.agentPageContext;
if (typeof mapper !== "function") continue;
try {
const context = mapper(match.data);
if (context) return context;
} catch {
// A broken mapper must not take the page down with it.
}

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.

🔴 The assistant loses track of which page you're on for most dashboard pages

The page description is built from the route's raw, still-encoded data (mapper(match.data) at apps/webapp/app/hooks/useAgentPageContext.ts:18) instead of the decoded data, so on most pages the description silently fails and the assistant only knows the URL.

Impact: On the majority of dashboard pages the assistant gets no details about what you're looking at, so the page-aware suggested prompts and "investigate" chips never appear.

Why raw match data doesn't match what the mappers parse

Many route loaders in this app return typedjson(...) / typeddefer(...) (e.g. apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx:146, ...env.$envParam.batches/route.tsx:110, ...alerts/route.tsx:100, ...deployments.$deploymentParam/route.tsx:83, ...runs._index/route.tsx:153). For those routes match.data is the serialized remix-typedjson envelope, which is why the app has useTypedMatchData calling deserializeRemix(match.data) (apps/webapp/app/hooks/useTypedMatchData.ts:39) and why every existing consumer (useOrganizations, useProject) goes through it.

The new mappers in apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.ts parse the deserialized shape (z.object({ queue: {...} }), z.object({ batch: {...} }), …). Given the envelope, every one of those safeParse calls fails, the mapper returns undefined, and useAgentPageContext falls through to { page: { kind: "other", path } }. Routes whose loaders return plain json() (e.g. the run detail route) are unaffected, which is why the unit tests — which feed the deserialized shape directly — still pass.

Prompt for agents
useAgentPageContext passes the raw `match.data` from useMatches() into each route's `handle.agentPageContext` mapper. For routes whose loaders use remix-typedjson (`typedjson`/`typeddefer`) the raw match data is the serialized envelope, not the payload — this is why the app already has `useTypedMatchData`/`useTypedMatchesData` (apps/webapp/app/hooks/useTypedMatchData.ts) which run `deserializeRemix(match.data)` before use. The mappers in apps/webapp/app/components/dashboard-agent/suggested-prompts/page-mappers.ts all zod-parse the deserialized payload shape, so on typedjson routes they fail and silently fall back to the generic `{ kind: "other" }` context, disabling page-aware prompts on those pages. Fix by deserializing the match data (or trying both the raw and deserialized shapes) before invoking the mapper, and add a test that feeds a typedjson-serialized payload through the hook path.
Open in Devin Review

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

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>

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.

🔍 Many docs buttons removed from page headers alongside the agent work

This PR deletes the docs/small "X docs" LinkButton from ~15 page headers (tasks, runs, queues, batches, alerts, deployments, branches, waitpoints, sessions, prompts, test, limits, bulk actions, API keys, env vars, agents). That is a broad, user-visible navigation change that isn't described in the PR summary and is independent of the agent panel — worth confirming it's a deliberate design decision rather than fallout from replacing the header accessories.

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]);

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.

🔍 Auto-navigation on navigate_to output fires from the mounted chat only

The effect re-scans the whole transcript for un-seen tool-navigate_to outputs on every message change and navigates to the last one. navigatedRef is seeded from initialMessages, so loaded history doesn't re-navigate — but the seen-set lives in the component, so remounting the same chat (switch away and back, or panel close/reopen) re-seeds from the stored transcript, which is fine, while a chat re-opened before its stream-delivered navigate part was persisted could navigate a second time. Worth confirming that navigate_to outputs are always persisted to the chat row before the panel can remount.

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