Skip to content

Commit 36b5c74

Browse files
committed
feat(webapp): move the in-dashboard agent launcher into the page header
The 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 toggles to "Collapse" while the panel is open. The panel and launcher read "Chat" in the UI. The launcher only renders on env-scoped pages where the agent is enabled, gated by the same feature flag, so nothing changes for users who don't have it.
1 parent f163c89 commit 36b5c74

5 files changed

Lines changed: 84 additions & 42 deletions

File tree

Lines changed: 28 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
1-
import { SparklesIcon } from "@heroicons/react/20/solid";
21
import { useState } from "react";
32
import {
43
ResizableHandle,
54
ResizablePanel,
65
ResizablePanelGroup,
76
} from "~/components/primitives/Resizable";
87
import { DashboardAgentPanel } from "./DashboardAgentPanel";
8+
import { DashboardAgentProvider } from "./dashboardAgentLauncher";
99

1010
/**
1111
* Mounts the dashboard agent in the env layout. Renders the page content
12-
* (`children` = the route Outlet); when the agent is open it splits the layout
13-
* into a resizable content + agent panel using the shared Resizable primitive,
14-
* with `autosaveId` persisting the width. When closed it's a floating launcher.
12+
* (`children` = the route Outlet) and shares the open/close state via context so
13+
* the page-header launcher (`DashboardAgentLauncher`) can toggle it. When open it
14+
* splits the layout into a resizable content + agent panel, `autosaveId` persists
15+
* the width.
1516
*
16-
* `hasAccess` is resolved server-side in the env layout loader (via
17-
* `canAccessDashboardAgent`: global env, admins/impersonators, then the
18-
* global/per-org feature flag, default off), so the launcher is hidden unless
19-
* the agent is enabled. The resource routes enforce the same check server-side.
17+
* `hasAccess` is resolved server-side in the env layout loader
18+
* (`canAccessDashboardAgent`); when false we render the content untouched and
19+
* never expose the context, so the launcher stays hidden. The resource routes
20+
* enforce the same check server-side.
2021
*/
2122
export function DashboardAgent({
2223
children,
@@ -31,36 +32,25 @@ export function DashboardAgent({
3132
return <div className="h-full min-h-0">{children}</div>;
3233
}
3334

34-
if (!open) {
35-
return (
36-
<div className="relative h-full min-h-0">
37-
<div className="h-full overflow-hidden">{children}</div>
38-
<button
39-
type="button"
40-
aria-label="Open the dashboard agent"
41-
onClick={() => setOpen(true)}
42-
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"
43-
>
44-
<SparklesIcon className="size-4 text-indigo-500" />
45-
Ask the agent
46-
</button>
47-
</div>
48-
);
49-
}
50-
5135
return (
52-
<ResizablePanelGroup
53-
orientation="horizontal"
54-
autosaveId="dashboard-agent-split"
55-
className="h-full min-h-0"
56-
>
57-
<ResizablePanel id="dashboard-content" min="320px">
58-
<div className="h-full overflow-hidden">{children}</div>
59-
</ResizablePanel>
60-
<ResizableHandle id="dashboard-agent-handle" />
61-
<ResizablePanel id="dashboard-agent-panel" default="380px" min="320px" max="720px">
62-
<DashboardAgentPanel onClose={() => setOpen(false)} />
63-
</ResizablePanel>
64-
</ResizablePanelGroup>
36+
<DashboardAgentProvider value={{ open, setOpen }}>
37+
{open ? (
38+
<ResizablePanelGroup
39+
orientation="horizontal"
40+
autosaveId="dashboard-agent-split"
41+
className="h-full min-h-0"
42+
>
43+
<ResizablePanel id="dashboard-content" min="320px">
44+
<div className="h-full overflow-hidden">{children}</div>
45+
</ResizablePanel>
46+
<ResizableHandle id="dashboard-agent-handle" />
47+
<ResizablePanel id="dashboard-agent-panel" default="380px" min="320px" max="720px">
48+
<DashboardAgentPanel onClose={() => setOpen(false)} />
49+
</ResizablePanel>
50+
</ResizablePanelGroup>
51+
) : (
52+
<div className="h-full min-h-0 overflow-hidden">{children}</div>
53+
)}
54+
</DashboardAgentProvider>
6555
);
6656
}

apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export function DashboardAgentChat({
101101
const res = await fetch(actionPath, { method: "POST", body });
102102
const data = (await res.json()) as { publicAccessToken?: string; error?: string };
103103
if (!res.ok || !data.publicAccessToken) {
104-
throw new Error(data.error ?? "The dashboard agent couldn't start.");
104+
throw new Error(data.error ?? "The chat couldn't start.");
105105
}
106106
return { publicAccessToken: data.publicAccessToken };
107107
},
@@ -112,7 +112,7 @@ export function DashboardAgentChat({
112112
const res = await fetch(actionPath, { method: "POST", body });
113113
const data = (await res.json()) as { token?: string; error?: string };
114114
if (!res.ok || !data.token) {
115-
throw new Error(data.error ?? "Couldn't refresh the dashboard agent token.");
115+
throw new Error(data.error ?? "Couldn't refresh the chat token.");
116116
}
117117
return data.token;
118118
},

apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function DashboardAgentHeader({
1414
}) {
1515
return (
1616
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
17-
<span className="text-sm font-medium text-text-bright">Dashboard agent</span>
17+
<span className="text-sm font-medium text-text-bright">Chat</span>
1818
<div className="flex items-center gap-0.5">
1919
<IconButton label="New chat" icon={PencilSquareIcon} onClick={onNewChat} />
2020
<IconButton
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { ChatBubbleLeftRightIcon, ChevronDoubleRightIcon } from "@heroicons/react/20/solid";
2+
import { createContext, useContext } from "react";
3+
import { cn } from "~/utils/cn";
4+
5+
type DashboardAgentContextValue = {
6+
open: boolean;
7+
setOpen: (open: boolean) => void;
8+
};
9+
10+
const DashboardAgentContext = createContext<DashboardAgentContextValue | null>(null);
11+
12+
export const DashboardAgentProvider = DashboardAgentContext.Provider;
13+
14+
// Null outside the env layout (no provider) or when the agent is gated off, so
15+
// the launcher self-hides everywhere it can't open.
16+
export function useDashboardAgent() {
17+
return useContext(DashboardAgentContext);
18+
}
19+
20+
export function DashboardAgentLauncher() {
21+
const agent = useDashboardAgent();
22+
if (!agent) {
23+
return null;
24+
}
25+
26+
const { open, setOpen } = agent;
27+
28+
return (
29+
<button
30+
type="button"
31+
aria-label={open ? "Collapse chat" : "Open chat"}
32+
onClick={() => setOpen(!open)}
33+
className={cn(
34+
"flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs text-text-bright transition",
35+
open
36+
? "border-charcoal-550 bg-charcoal-750"
37+
: "border-charcoal-650 bg-background-bright hover:border-charcoal-550"
38+
)}
39+
>
40+
{open ? (
41+
<ChevronDoubleRightIcon className="size-3.5 text-text-dimmed" />
42+
) : (
43+
<ChatBubbleLeftRightIcon className="size-3.5 text-indigo-500" />
44+
)}
45+
{open ? "Collapse" : "Chat"}
46+
</button>
47+
);
48+
}

apps/webapp/app/components/primitives/PageHeader.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Header2 } from "./Headers";
88
import { LoadingBarDivider } from "./LoadingBarDivider";
99
import { SimpleTooltip } from "./Tooltip";
1010
import { EnvironmentBanner } from "../navigation/EnvironmentBanner";
11+
import { DashboardAgentLauncher } from "../dashboard-agent/dashboardAgentLauncher";
1112

1213
type WithChildren = {
1314
children: React.ReactNode;
@@ -24,7 +25,10 @@ export function NavBar({ children }: WithChildren) {
2425
return (
2526
<div>
2627
<div className="grid h-10 w-full grid-rows-[auto_1px] bg-background-bright">
27-
<div className="flex w-full items-center justify-between pl-3 pr-2">{children}</div>
28+
<div className="flex w-full items-center gap-2 pl-3 pr-2">
29+
<div className="flex flex-1 items-center justify-between">{children}</div>
30+
<DashboardAgentLauncher />
31+
</div>
2832
<LoadingBarDivider isLoading={isLoading} />
2933
</div>
3034
{showUpgradePrompt.shouldShow && organization ? <UpgradePrompt /> : <EnvironmentBanner />}

0 commit comments

Comments
 (0)