feat(webapp): move the in-dashboard agent launcher into the page header (#4053)
<img width="2400" height="1794" alt="chat-ui-closed" src="https://github.com/user-attachments/assets/35016a72-c6d2-4b6a-8760-b1da5b4a166c" /> <img width="2400" height="1794" alt="chat-ui-open" src="https://github.com/user-attachments/assets/25b3df60-9aa2-40fd-a1ba-1447eeee4c52" /> ## Summary The in-dashboard agent was launched from a button pinned to the bottom-right of every page, which floated over page controls (for example the run inspector's action bar). It now opens from a compact "Chat" button on the far right of the page header, and the same button toggles to "Collapse" while the panel is open. The panel and launcher are labelled "Chat" in the UI. The launcher only renders on env-scoped pages where the agent is enabled (same feature flag gating), so it stays hidden for everyone who doesn't have it. The existing "Ask AI" support button is untouched; it stays in place until the agent is turned on by default. ## How it works `DashboardAgent` (env layout) shares the open/close state through a small context, and `NavBar` renders a launcher that self-hides whenever that context is absent. No floating overlay, and the launcher can't appear on pages where the agent can't open.
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { SparklesIcon } from "@heroicons/react/20/solid";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ResizableHandle,
|
||||
@@ -6,17 +5,19 @@ import {
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { DashboardAgentPanel } from "./DashboardAgentPanel";
|
||||
import { DashboardAgentProvider } from "./dashboardAgentLauncher";
|
||||
|
||||
/**
|
||||
* Mounts the dashboard agent in the env layout. Renders the page content
|
||||
* (`children` = the route Outlet); when the agent is open it splits the layout
|
||||
* into a resizable content + agent panel using the shared Resizable primitive,
|
||||
* with `autosaveId` persisting the width. When closed it's a floating launcher.
|
||||
* (`children` = the route Outlet) and shares the open/close state via context so
|
||||
* the page-header launcher (`DashboardAgentLauncher`) can toggle it. When open it
|
||||
* splits the layout into a resizable content + agent panel, `autosaveId` persists
|
||||
* the width.
|
||||
*
|
||||
* `hasAccess` is resolved server-side in the env layout loader (via
|
||||
* `canAccessDashboardAgent`: global env, admins/impersonators, then the
|
||||
* global/per-org feature flag, default off), so the launcher is hidden unless
|
||||
* the agent is enabled. The resource routes enforce the same check server-side.
|
||||
* `hasAccess` is resolved server-side in the env layout loader
|
||||
* (`canAccessDashboardAgent`); when false we render the content untouched and
|
||||
* never expose the context, so the launcher stays hidden. The resource routes
|
||||
* enforce the same check server-side.
|
||||
*/
|
||||
export function DashboardAgent({
|
||||
children,
|
||||
@@ -31,36 +32,25 @@ export function DashboardAgent({
|
||||
return <div className="h-full min-h-0">{children}</div>;
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="relative h-full min-h-0">
|
||||
<div className="h-full overflow-hidden">{children}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open the dashboard agent"
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-4 right-4 z-40 flex items-center gap-1.5 rounded-full border border-charcoal-650 bg-background-bright px-3.5 py-2 text-sm text-text-bright shadow-lg transition hover:border-charcoal-550"
|
||||
>
|
||||
<SparklesIcon className="size-4 text-indigo-500" />
|
||||
Ask the agent
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
autosaveId="dashboard-agent-split"
|
||||
className="h-full min-h-0"
|
||||
>
|
||||
<ResizablePanel id="dashboard-content" min="320px">
|
||||
<div className="h-full overflow-hidden">{children}</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="dashboard-agent-handle" />
|
||||
<ResizablePanel id="dashboard-agent-panel" default="380px" min="320px" max="720px">
|
||||
<DashboardAgentPanel onClose={() => setOpen(false)} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<DashboardAgentProvider value={{ open, setOpen }}>
|
||||
{open ? (
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
autosaveId="dashboard-agent-split"
|
||||
className="h-full min-h-0"
|
||||
>
|
||||
<ResizablePanel id="dashboard-content" min="320px">
|
||||
<div className="h-full overflow-hidden">{children}</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="dashboard-agent-handle" />
|
||||
<ResizablePanel id="dashboard-agent-panel" default="380px" min="320px" max="720px">
|
||||
<DashboardAgentPanel onClose={() => setOpen(false)} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
<div className="h-full min-h-0 overflow-hidden">{children}</div>
|
||||
)}
|
||||
</DashboardAgentProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export function DashboardAgentChat({
|
||||
const res = await fetch(actionPath, { method: "POST", body });
|
||||
const data = (await res.json()) as { publicAccessToken?: string; error?: string };
|
||||
if (!res.ok || !data.publicAccessToken) {
|
||||
throw new Error(data.error ?? "The dashboard agent couldn't start.");
|
||||
throw new Error(data.error ?? "The chat couldn't start.");
|
||||
}
|
||||
return { publicAccessToken: data.publicAccessToken };
|
||||
},
|
||||
@@ -112,7 +112,7 @@ export function DashboardAgentChat({
|
||||
const res = await fetch(actionPath, { method: "POST", body });
|
||||
const data = (await res.json()) as { token?: string; error?: string };
|
||||
if (!res.ok || !data.token) {
|
||||
throw new Error(data.error ?? "Couldn't refresh the dashboard agent token.");
|
||||
throw new Error(data.error ?? "Couldn't refresh the chat token.");
|
||||
}
|
||||
return data.token;
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ export function DashboardAgentHeader({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
|
||||
<span className="text-sm font-medium text-text-bright">Dashboard agent</span>
|
||||
<span className="text-sm font-medium text-text-bright">Chat</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<IconButton label="New chat" icon={PencilSquareIcon} onClick={onNewChat} />
|
||||
<IconButton
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ChatBubbleLeftRightIcon, ChevronDoubleRightIcon } from "@heroicons/react/20/solid";
|
||||
import { createContext, useContext } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type DashboardAgentContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const DashboardAgentContext = createContext<DashboardAgentContextValue | null>(null);
|
||||
|
||||
export const DashboardAgentProvider = DashboardAgentContext.Provider;
|
||||
|
||||
// Null outside the env layout (no provider) or when the agent is gated off, so
|
||||
// the launcher self-hides everywhere it can't open.
|
||||
export function useDashboardAgent() {
|
||||
return useContext(DashboardAgentContext);
|
||||
}
|
||||
|
||||
export function DashboardAgentLauncher() {
|
||||
const agent = useDashboardAgent();
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { open, setOpen } = agent;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={open ? "Collapse chat" : "Open chat"}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs text-text-bright transition",
|
||||
open
|
||||
? "border-charcoal-550 bg-charcoal-750"
|
||||
: "border-charcoal-650 bg-background-bright hover:border-charcoal-550"
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDoubleRightIcon className="size-3.5 text-text-dimmed" />
|
||||
) : (
|
||||
<ChatBubbleLeftRightIcon className="size-3.5 text-indigo-500" />
|
||||
)}
|
||||
{open ? "Collapse" : "Chat"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Header2 } from "./Headers";
|
||||
import { LoadingBarDivider } from "./LoadingBarDivider";
|
||||
import { SimpleTooltip } from "./Tooltip";
|
||||
import { EnvironmentBanner } from "../navigation/EnvironmentBanner";
|
||||
import { DashboardAgentLauncher } from "../dashboard-agent/dashboardAgentLauncher";
|
||||
|
||||
type WithChildren = {
|
||||
children: React.ReactNode;
|
||||
@@ -24,7 +25,10 @@ export function NavBar({ children }: WithChildren) {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid h-10 w-full grid-rows-[auto_1px] bg-background-bright">
|
||||
<div className="flex w-full items-center justify-between pl-3 pr-2">{children}</div>
|
||||
<div className="flex w-full items-center gap-2 pl-3 pr-2">
|
||||
<div className="flex flex-1 items-center justify-between">{children}</div>
|
||||
<DashboardAgentLauncher />
|
||||
</div>
|
||||
<LoadingBarDivider isLoading={isLoading} />
|
||||
</div>
|
||||
{showUpgradePrompt.shouldShow && organization ? <UpgradePrompt /> : <EnvironmentBanner />}
|
||||
|
||||
Reference in New Issue
Block a user