Files
app/frontend/src/components/Sidebar/AppSidebar.tsx
T
stroblme 7240b7b823 Add the assistant: a chat that drives the MCP tools in-process
A provider set under Settings (OpenAI-compatible; key encrypted in
flow-data/ai.json) turns on an Assistant panel in the sidebar. Each turn runs
the FastMCP tools in-process as the person chatting, streams the rounds over
SSE and keeps one transcript per user. The panel floats on any screen and
shares the editor's lane: a settings panel keeps the top half, the chat the
bottom. The MCP client is now built once, for the endpoint and the chat.
2026-09-16 23:28:23 +02:00

163 lines
5.9 KiB
TypeScript

import { useQuery } from "@tanstack/react-query"
import {
ArrowLeft,
Bell,
BookOpen,
Bot,
FlaskConical,
Home,
KeyRound,
LayoutDashboard,
LogOut,
Package,
Search,
Server,
Settings,
Users,
Workflow,
} from "lucide-react"
import { useState } from "react"
import { aiStatusQueryOptions, ChatPanel } from "@/components/Chat/ChatPanel"
import { chatStore } from "@/components/Chat/chatStore"
import { GlobalSearch } from "@/components/Common/GlobalSearch"
import { Logo } from "@/components/Common/Logo"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarTrigger,
} from "@/components/ui/sidebar"
import useAuth from "@/hooks/useAuth"
import { portalConfig } from "@/lib/portal"
import { useShortcuts } from "@/lib/shortcuts"
import { type Item, Main } from "./Main"
/**
* Where the documentation is published.
*
* A constant rather than something derived: an instance reached over a LAN
* address, or at localhost, has no domain to build this from, and the docs are
* one site for all of them.
*/
const DOCS_URL = "https://docs.fluksio.com"
const baseItems: Item[] = [
// "Home" rather than "Dashboard": dashboards are their own thing now, and
// the brain and the health screens are sections of Home rather than routes.
{ icon: Home, title: "Home", path: "/" },
{ icon: Workflow, title: "Flows", path: "/flows" },
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
// What a batch flow leaves behind. Beside the flows rather than under Home:
// an experiment log is browsed, not glanced at.
{ icon: FlaskConical, title: "Runs", path: "/runs" },
// Both are engine-wide operator settings rather than personal ones, so they
// sit here and not among the per-user tabs under Settings.
{ icon: KeyRound, title: "Secrets", path: "/secrets" },
{ icon: Package, title: "Modules", path: "/modules" },
{ icon: Server, title: "Workers", path: "/workers" },
{ icon: Bell, title: "Alerts", path: "/alerts" },
]
export function AppSidebar() {
const { user: currentUser, logout } = useAuth()
const portal = portalConfig()
const [searchOpen, setSearchOpen] = useState(false)
// Mounted by both shells, so this one binding covers every screen. Listed as
// firing inside text entry too, because reaching anything at all should not
// depend on where the caret happens to be.
useShortcuts({ "mod+k": () => setSearchOpen((open) => !open) }, ["mod+k"])
const withAdmin = currentUser?.is_superuser
? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }]
: baseItems
// Reached through a portal, the way out is back to the instances list —
// and the session belongs to the portal, so logging out is its business.
const items: Item[] = portal
? [
{
icon: ArrowLeft,
title: "All instances",
onClick: () => window.location.assign(portal.portalUrl),
},
...withAdmin,
]
: withAdmin
// A new tab rather than a navigation: the docs are read beside the work,
// not instead of it.
const docs: Item = {
icon: BookOpen,
title: "Documentation",
onClick: () => window.open(DOCS_URL, "_blank", "noopener"),
}
const search: Item = {
icon: Search,
title: "Search",
onClick: () => setSearchOpen(true),
}
// Only once an operator has set a provider up: with none there is nothing
// to open, and nothing on screen says the feature exists.
const assistant = useQuery(aiStatusQueryOptions())
const tools: Item[] = assistant.data?.available
? [{ icon: Bot, title: "Assistant", onClick: chatStore.toggle }, search]
: [search]
const footerItems: Item[] = portal
? [...tools, docs, { icon: Settings, title: "Settings", path: "/settings" }]
: [
...tools,
docs,
{ icon: Settings, title: "Settings", path: "/settings" },
{ icon: LogOut, title: "Log Out", onClick: logout },
]
return (
<>
{/* Floating frosted chrome over whatever surface the shell paints; see the
root DESIGN-GUIDELINES.md → Shells and → Overlay surfaces & content
chips. */}
<Sidebar
collapsible="icon"
variant="floating"
className="[&>[data-sidebar=sidebar]]:bg-card/80 [&>[data-sidebar=sidebar]]:backdrop-blur-md [&>[data-sidebar=sidebar]]:shadow-e2"
>
<SidebarHeader className="px-4 py-6 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center">
<div className="flex w-full items-center justify-between gap-2 group-data-[collapsible=icon]:justify-center">
{/* Collapsed, the rail has room for one thing, and that is the way
back out. `shrink-0` because the rail's width animates while the
logo is already back: a flex item short of room is squeezed, and
a wordmark would rather be clipped than squashed. */}
<span className="shrink-0 group-data-[collapsible=icon]:hidden">
<Logo variant="responsive" />
</span>
{/* On a phone the sidebar is a sheet with its own way in and out. */}
<SidebarTrigger className="hidden shrink-0 text-muted-foreground md:inline-flex" />
</div>
</SidebarHeader>
<SidebarContent>
<Main items={items} />
</SidebarContent>
{/* Main already pads horizontally; the footer only adds the bottom gap. */}
<SidebarFooter className="px-0">
<Main items={footerItems} />
</SidebarFooter>
</Sidebar>
{/* Outside the sidebar: on a phone that is a sheet, and a dialog is not
one of its children. */}
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
{/* Mounted once, here, for the same reason: both shells carry the
sidebar, and the chat follows whoever opened it from screen to
screen. */}
<ChatPanel />
</>
)
}
export default AppSidebar