Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 28 additions & 38 deletions apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import { SparklesIcon } from "@heroicons/react/20/solid";
import { useState } from "react";
import {
ResizableHandle,
ResizablePanel,
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,
Expand All @@ -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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
},
Expand All @@ -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;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
}
6 changes: 5 additions & 1 deletion apps/webapp/app/components/primitives/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 />}
Expand Down