Uh oh!
There was an error while loading. Please reload this page.
feat(webapp): dashboard agent — UI - #4529
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
WalkthroughThe pull request expands the Dashboard Agent into a page-aware chat experience. It adds shared channel routing, fullscreen controls, chat history, quotas, transcript handling, structured view blocks, investigations, reports, suggested prompts, and route metadata. It removes selected page-header Docs controls and adds investigation actions for failed runs, waiting runs, and degraded queues. Tests cover routing, prompts, rendering, transcript state, quotas, navigation, accessibility, and report parity. 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
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. Comment |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
@trigger.dev/buildtrigger.dev@trigger.dev/core@trigger.dev/python@trigger.dev/react-hooks@trigger.dev/redis-worker@trigger.dev/rsc@trigger.dev/schema-to-json@trigger.dev/sdkcommit: |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
bd4d4a0 to
887f5b6Compare
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
887f5b6 to
17a0f07Compare
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
17a0f07 to
2460d14CompareThe panel, the page-context marks on the pages the agent reads, and the entry points.
Ask AI (Kapa) owns the two entry points it had before the dashboard agent replaced it: Cmd-I, and the `?aiHelp=` deep link the CLI's "Get a fix for this error using AI" line points at. `AskAIRoot` mounts in the `_app` layout, above every signed-in page, so Cmd-I reaches it from org-level pages too and the dialog outlives whatever opened it. The agent no longer reads deep links at all: nothing produced its `?ask=` param except the CLI redirect, and both readers consume the param, so a live agent reader would always beat Kapa to it. It stays the fall-through — where Kapa cannot open (self-hosted, or no website id), both channels land on the agent instead of dead-ending.
…the pipeline emits The fixture still set `facts.staleReason`, renamed to `untrustworthyReason` three commits before the caveat started reading it, so the branch's only trust snapshot fell back to "could not be verified" for a report whose reason is known.
The scheduled example lost its import line to the standard one, so copying it gave code that does not compile.
Without org/project/env context the run button rendered but did nothing.
Shortcuts can now ask for the browser default to be prevented, and the agent's keystroke does.
Selecting a stored chat with no messages dropped you into a fresh draft, as if the chat had been deleted.
Radix tooltip content is not the accessible name of its trigger, so the icon-only ask-agent button and the two deploy docs links announced as unnamed controls. Name them explicitly and pass asChild so the tooltip trigger stops wrapping them in a second button. Adds a source scan that fails on the next SimpleTooltip with an unnamed or double-wrapped control, with the pre-existing sites baselined.
A limit of 0 is zero capacity, not saturation: running >= 0 holds for every queue, so any backlog marked the queue degraded and offered Investigate, while the agent's own suggested prompt stayed silent. One predicate now decides it for the queue detail page, the queues list badge and the page mappers.
Retry appended the last user message again, so the failed turn stayed in the transcript and its text was sent twice. It now regenerates once the agent has started answering, and otherwise re-sends the failed turn under its own id.
…e reader's clock Bar timestamps came from Date.now() during render, so the same bar reported a different time on every re-render and a server pass disagreed with the client. They now come from the view model's generatedAt, which the schema already describes as the timestamp the renderer must not invent. Moves the arithmetic into report-spark.ts to keep it clock-free and testable, and drops the unreachable Math.max on the slice end while doing so.
ViewBlocks looked each surviving block's index up with indexOf inside the render loop: quadratic, and two occurrences of the same block object both answered with the first index, so they collided on one React key. latestRevisionEntries carries each survivor's position out instead.
setSearchParams only starts the navigation that drops the param, so a render before it commits saw the question again and asked it a second time. The reader now records what it sent and forgets it once the URL no longer carries it, so a later visit with the same question still works.
A request still in flight at unmount rejected afterwards, and the catch scheduled a retry that fetched again and set state for a component that was gone. The hook tracks whether it is still mounted and neither records nor reschedules once it is not.
The character counter's live region only entered the DOM at the warning point, and several screen readers only announce updates for a region that was already there; it is now always mounted and empty until there is something to say. The history trigger's aria-label replaced the chat title it shows, so a speech-input user could not activate it by the words on it. The title now leads the accessible name.
…anel justify-center on a scrolling column overflows equally in both directions, and nothing can scroll back past the origin, so at the docked panel's narrowest the heading and composer were unreachable. The child centres with m-auto, which gives its space up once there is none to spare.
new URL(environmentPath, origin) ignores origin when the path is absolute, and the result goes straight into redirect(). Today's only caller passes a builder-generated internal path, so this closes the gap rather than a hole.
A preview branch named `env` put a second `env` segment in the path and `lastIndexOf` picked it, shifting every index derived from it.
… keystroke Between the warning point and the limit the region announced a new count per character; it now steps in 200s and names the limit on reaching it.
2460d14 to
9447128Compare| <AskAIRoot> | ||
| {() => ( | ||
| <AppContainer> | ||
| <Outlet /> | ||
| </AppContainer> | ||
| )} | ||
| </AskAIRoot> |
There was a problem hiding this comment.
🔴 Whole dashboard is torn down and rebuilt right after every page load on cloud
The entire signed-in dashboard is placed inside a helper that only renders its real content after the page becomes interactive (<AskAIRoot> at apps/webapp/app/routes/_app/route.tsx:33-39), so on managed cloud every page is thrown away and rebuilt from scratch immediately after it loads.
Impact: On trigger.dev cloud, every page load discards and recreates the whole dashboard once — page state resets, one-time startup work runs twice (duplicate fetches, re-opened live subscriptions), and the page can visibly flash.
Why the subtree remounts: ClientOnly swaps the element type at this position
AskAIRoot (apps/webapp/app/components/AskAI.tsx:93-108) renders <ClientOnly fallback={<>{children(undefined)}</>}>{() => <AskAIRootProvider …>{children}</AskAIRootProvider>}</ClientOnly> whenever askAiCanOpen(availability) is true (managed cloud with KAPA_AI_WEBSITE_ID set).
ClientOnly renders the fallback during SSR/hydration and then, in an effect, renders children(). At that slot the element type changes from a Fragment (holding <AppContainer><Outlet/></AppContainer>) to <AskAIRootProvider>, so React unmounts the whole subtree and mounts a fresh one. Every component under _app — i.e. the entire dashboard, including the agent panel host, Electric/SSE subscribers and any mount-time fetch — is unmounted and re-mounted once per page load.
Before this PR AskAIRoot only wrapped the Help & Feedback popover, so the same swap affected a tiny subtree.
A stable-tree arrangement avoids it, e.g. render the app normally and mount AskAIRoot beside it:
<AppContainer><AskAIRoot>{()=>null}</AskAIRoot><Outlet/></AppContainer>(or hoist the Kapa provider so it is always the same component type on both renders).
Prompt for agents
In apps/webapp/app/routes/_app/route.tsx the whole signed-in app (`<AppContainer><Outlet/></AppContainer>`) is passed as the children callback of `AskAIRoot`. `AskAIRoot` (apps/webapp/app/components/AskAI.tsx) renders that callback inside `ClientOnly`, whose fallback is `<>{children(undefined)}</>`. Because the element type at that position changes from a Fragment to `AskAIRootProvider` once hydration completes, React unmounts and remounts the entire app subtree once per page load on managed cloud (where askAiCanOpen is true). That resets all component state and re-runs every mount effect (duplicate fetches, re-established realtime subscriptions).
Fix by keeping the element tree stable across the ClientOnly swap. Options: mount AskAIRoot as a sibling that renders nothing (`<AskAIRoot>{() => null}</AskAIRoot>` next to the Outlet), or restructure AskAIRoot so the same component wraps the children in both the fallback and the hydrated branch (e.g. always render a provider component that internally no-ops until hydrated).
Was this helpful? React with 👍 or 👎 to provide feedback.
| try { | ||
| const res = await fetch(actionPath, { method: "POST", body }); | ||
| const data = (await res.json()) as { path?: string }; | ||
| if (!res.ok || !data.path) throw new Error(`Resolve failed (${res.status})`); | ||
| navigate(appendRunFilters(data.path, intent.filters)); | ||
| } catch (error) { | ||
| console.error("Dashboard agent: failed to resolve a navigate target", error); | ||
| toast.error("Couldn't open that page."); | ||
| } |
There was a problem hiding this comment.
🟡 Agent links to a source file send the user to a broken in-app page
When the agent offers to open a source file, the destination is handed to the in-app router as if it were a dashboard page (navigate(...) at apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx:219) even though the server said it points at an external site, so the user lands on a not-found page instead of the file.
Impact: Clicking such a suggestion navigates to a broken URL inside the dashboard rather than opening the linked file.
The resolver's `external` flag is dropped by the client
resolveTriggerUri returns { url, external: true } with an absolute https://github.com/... URL for trigger://…/source/… URIs (apps/webapp/app/services/resolveTriggerUri.server.ts:140-151), and the resolve action forwards external in its JSON (apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts:334-339).
goTo reads only data.path and calls React Router's navigate(), which treats an absolute URL string as an in-app path and resolves it relative to the current location. A navigate intent may carry any grammar-valid trigger:// URI (the actions-block schema validates only that it is a trigger URI — internal-packages/dashboard-agent-contracts/src/blocks.ts:104-124), so a source target is reachable whenever code-mode tools hand the model one.
Additionally, appendRunFilters would strip the origin from such an absolute URL (apps/webapp/app/components/dashboard-agent/navigate-target.ts:8-32) if filters were present.
Fix: read external from the resolve response and, when set, open it with window.open/location.assign (or render it as a link) instead of navigate().
Prompt for agents
In DashboardAgentChat.tsx `goTo`, the `resolve` action's response is parsed as `{ path?: string }` and always passed to React Router's `navigate()`. The server also returns `external: true` with an absolute off-origin URL for `trigger://…/source/…` targets (services/resolveTriggerUri.server.ts). Navigating to an absolute URL through the SPA router resolves it as an in-app path and produces a broken navigation; `appendRunFilters` would also strip its origin.
Parse `external` (and ideally `label`) from the response and branch: for external destinations open the URL in a new tab (window.open with noopener) or assign location, and only call `navigate()` (with appendRunFilters) for same-origin dashboard paths.
Was this helpful? React with 👍 or 👎 to provide feedback.
Uh oh!
There was an error while loading. Please reload this page.
| const loadHistory = useCallback(async () => { | ||
| const res = await fetch(actionPath); | ||
| if (res.ok) { | ||
| const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] }; | ||
| setChats(data.chats ?? []); | ||
| } | ||
| }, [actionPath]); | ||
| if (historyInFlight.current) return historyInFlight.current; | ||
| const request = (async () => { | ||
| try { | ||
| const res = await fetch(actionPath); | ||
| if (!res.ok) throw new Error(`History request failed (${res.status})`); | ||
| const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] }; | ||
| setChats(data.chats ?? []); | ||
| } catch (error) { | ||
| console.error("Dashboard agent: failed to load chat history", error); | ||
| toast.error("We couldn't load your previous chats. Try again in a moment."); | ||
| } finally { | ||
| historyInFlight.current = null; | ||
| } | ||
| })(); | ||
| historyInFlight.current = request; | ||
| return request; | ||
| }, [actionPath, toast]); |
There was a problem hiding this comment.
🟡 Chat list can miss the newly created chat and updated titles after a reply finishes
A request to reload the chat list is silently skipped (if (historyInFlight.current) return historyInFlight.current; at apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx:131) whenever another reload is still running, so a refresh asked for at the end of a reply can be answered with results fetched before that reply existed.
Impact: The chat list in the panel header can keep showing a stale name, or omit the chat that was just started, until something else triggers a reload.
How the de-duplication loses the newer request
loadHistory returns the in-flight promise instead of scheduling a follow-up fetch. It is called from several places that care about different points in time: panel mount, opening the history menu (apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx:341), after a delete (:315), and — the important one — onTurnSettled when a turn finishes (:373, wired to the settle effect in apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx:273).
If a mount-time or menu-open fetch is still in flight when the first turn settles, the settle-triggered refresh resolves against data captured before the chat row / generated title was written, and no further fetch is issued. A "coalesce into a trailing refetch" pattern (mark that another run is wanted and re-run once the current one finishes) avoids this.
Prompt for agents
`loadHistory` in DashboardAgentPanel.tsx de-duplicates concurrent calls by returning the in-flight promise. That drops any refresh requested while a fetch is running, including the one fired by `onTurnSettled` when the first turn of a new chat completes — which is exactly when the chat row and generated title become visible. Change the de-duplication to coalesce into a trailing refetch: keep the in-flight guard, but record that another run was requested and re-run once the current one settles, so the last requester always sees fresh data.
Was this helpful? React with 👍 or 👎 to provide feedback.
| @@ -0,0 +1,63 @@ | |||
| import { describe, expect, it } from "vitest"; | |||
| import { hotkeyOptions } from "~/hooks/useShortcutKeys"; | |||
| import { LEGACY_ASK_AI_SHORTCUT, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher"; | |||
There was a problem hiding this comment.
🔍 Test imports a symbol that no longer exists
agent-shortcuts.test.ts imports LEGACY_ASK_AI_SHORTCUT from ./dashboardAgentLauncher, but that module only exports TOGGLE_PANEL_SHORTCUT (see apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx:7-15). The ⌘I shortcut was moved to ASK_AI_SHORTCUT in apps/webapp/app/components/dashboard-agent/ask-ai-channels.ts:11. The suite will fail to run as written.
Was this helpful? React with 👍 or 👎 to provide feedback.
| export function isQueueAtCapacity({ running, queued, limit }: QueueCapacity): boolean { | ||
| if (limit === null || limit === undefined || limit <= 0) return false; | ||
| return running >= limit && queued > 0; | ||
| } |
There was a problem hiding this comment.
🔍 Zero-limit queues change health label from "At capacity" to "Backlogged"
isQueueAtCapacity now short-circuits on limit <= 0, whereas the inlined predicate it replaces (running >= limit && queued > 0) reported a zero-limit queue with a backlog as "At capacity". A queue whose concurrency limit is explicitly 0 (paused-by-limit) will now show "Backlogged" in the queues list and will no longer emit a concurrency_saturation signal to the agent. The new semantics are arguably more accurate, but it is a user-visible label change in the queues table that isn't called out in the PR description or the server-changes note.
Was this helpful? React with 👍 or 👎 to provide feedback.
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
handle.agentPageContexton 47 routes, ~20 lines each.?aiHelp=links keep working.Notes
canAccessDashboardAgent; no behavior change with the flag off.