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.
163 lines
5.9 KiB
TypeScript
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
|