Add a global search, and stop the sidebar logo squeezing
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m53s
pre-commit / pre-commit (push) Failing after 2m13s
Test Backend / test-backend (push) Successful in 2m38s
Compose Smoke Test / test-compose (push) Successful in 38s
Playwright Tests / merge-reports (push) Successful in 1m8s

`GET /api/v1/search/` hands the client one flat index of everything worth
jumping to — flows and the nodes inside them, dashboards and the widgets on
them, panels, secrets, modules, workers and alert channels — and cmdk matches
it in the browser, so results narrow while typing without a round trip per
keystroke. A node hit is the one thing no list endpoint could answer: it opens
its flow with that node in focus.

The panel is reached from **Search** above Documentation in the sidebar, or
⌘K anywhere. The flow canvas palette moves to ⌘P, being the narrower of the two.

The panels dialog gains an address (`/dashboards?panels`) so a panel hit has
somewhere to land, and the sidebar logo gets `shrink-0`: the rail's width
animates while the logo is already back, and a flex item short of room is
squeezed rather than clipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vGH7jqcXxWKP9wZFPyVdU
This commit is contained in:
2026-08-28 22:19:08 +02:00
co-authored by Claude Opus 5
parent 971bd430c7
commit 4215e057d1
17 changed files with 642 additions and 39 deletions
+37
View File
@@ -2960,6 +2960,43 @@ export const RunRequestSchema = {
title: 'RunRequest'
} as const;
export const SearchEntrySchema = {
properties: {
category: {
type: 'string',
enum: ['flow', 'node', 'dashboard', 'widget', 'panel', 'secret', 'module', 'worker', 'alert'],
title: 'Category'
},
name: {
type: 'string',
title: 'Name'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
parent: {
type: 'string',
title: 'Parent',
default: ''
},
kind: {
type: 'string',
title: 'Kind',
default: ''
}
},
type: 'object',
required: ['category', 'name'],
title: 'SearchEntry',
description: `One thing somebody might be looking for.
Deliberately not a route: where a category lands is the frontend's business,
and it already owns the router. This says what the thing is and what it is
called, which is all the matching needs.`
} as const;
export const SecretNamesSchema = {
properties: {
data: {
File diff suppressed because one or more lines are too long
+19
View File
@@ -1052,6 +1052,23 @@ export type RunRequest = {
};
};
/**
* One thing somebody might be looking for.
*
* Deliberately not a route: where a category lands is the frontend's business,
* and it already owns the router. This says what the thing is and what it is
* called, which is all the matching needs.
*/
export type SearchEntry = {
category: 'flow' | 'node' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert';
name: string;
title?: string;
parent?: string;
kind?: string;
};
export type category = 'flow' | 'node' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert';
export type SecretNames = {
data: Array<(string)>;
count: number;
@@ -1830,6 +1847,8 @@ export type RunsCompareMetricData = {
export type RunsCompareMetricResponse = (SeriesAnswer);
export type SearchReadSearchIndexResponse = (Array<SearchEntry>);
export type SecretsReadSecretsResponse = (SecretNames);
export type SecretsSaveSecretData = {
@@ -0,0 +1,185 @@
import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import {
Bell,
Box,
KeyRound,
LayoutDashboard,
LayoutGrid,
type LucideIcon,
MonitorSmartphone,
Package,
Server,
Workflow,
} from "lucide-react"
import { useState } from "react"
import { type SearchEntry, SearchService } from "@/client"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
export const searchQueryOptions = () => ({
queryKey: ["search"] as const,
queryFn: () => SearchService.readSearchIndex(),
staleTime: 30_000,
})
/** The categories, in the order they are offered, with what to draw each as. */
const GROUPS: {
category: SearchEntry["category"]
label: string
icon: LucideIcon
}[] = [
{ category: "flow", label: "Flows", icon: Workflow },
{ category: "node", label: "Nodes", icon: Box },
{ category: "dashboard", label: "Dashboards", icon: LayoutDashboard },
{ category: "widget", label: "Widgets", icon: LayoutGrid },
{ category: "panel", label: "Panels", icon: MonitorSmartphone },
{ category: "secret", label: "Secrets", icon: KeyRound },
{ category: "module", label: "Modules", icon: Package },
{ category: "worker", label: "Workers", icon: Server },
{ category: "alert", label: "Alerts", icon: Bell },
]
/** The second line: where the thing lives, and what kind it is. */
function hint(entry: SearchEntry): string {
return [entry.parent, entry.kind].filter(Boolean).join(" · ")
}
/**
* Everything in this installation, by name, from anywhere.
*
* The whole index arrives in one fetch and `cmdk` does the matching, so results
* narrow as they are typed without a round trip per keystroke.
*
* ponytail: every entry is rendered and cmdk hides the ones that do not match.
* Cap the groups if an installation ever grows big enough to feel it.
*/
export function GlobalSearch({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const navigate = useNavigate()
const [query, setQuery] = useState("")
const { data } = useQuery({ ...searchQueryOptions(), enabled: open })
// Picking an item navigates, which can interrupt the dialog's exit animation
// and leave its overlay swallowing clicks — the same reason the flow canvas
// palette unmounts outright rather than fading out.
if (!open) return null
const entries = data ?? []
const typing = query.trim().length > 0
const go = (entry: SearchEntry) => {
onOpenChange(false)
setQuery("")
switch (entry.category) {
case "flow":
return navigate({
to: "/flows/$flowName",
params: { flowName: entry.name },
})
case "node":
return navigate({
to: "/flows/$flowName",
params: { flowName: entry.parent ?? "" },
search: { node: entry.name },
})
case "dashboard":
return navigate({
to: "/dashboards/$name",
params: { name: entry.name },
})
case "widget":
return navigate({
to: "/dashboards/$name",
params: { name: entry.parent ?? "" },
})
// Panels are managed in a dialog on the dashboards screen, which opens
// itself when the address says so.
case "panel":
return navigate({ to: "/dashboards", search: { panels: true } })
case "secret":
return navigate({ to: "/secrets" })
case "module":
return navigate({ to: "/modules" })
case "worker":
return navigate({ to: "/workers" })
case "alert":
return navigate({ to: "/alerts" })
}
}
return (
// Frosted chrome, a little above centre. `top-[40%]` against the dialog's
// own `-translate-y-1/2` puts the panel's middle at two fifths of the
// viewport; the inner Command paints its own surface, which has to give way
// to this one.
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Search"
description="Find a flow, node, dashboard or widget"
showCloseButton={false}
className="top-[40%] bg-popover/80 shadow-e3 backdrop-blur-md sm:max-w-xl [&_[data-slot=command]]:bg-transparent"
>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Search flows, nodes, dashboards, widgets…"
data-testid="global-search-input"
/>
<CommandList className="max-h-[min(24rem,60svh)]">
{typing ? (
<>
<CommandEmpty>Nothing matches that.</CommandEmpty>
{GROUPS.map(({ category, label, icon: Icon }) => {
const found = entries.filter(
(entry) => entry.category === category,
)
if (found.length === 0) return null
return (
<CommandGroup key={category} heading={label}>
{found.map((entry) => (
<CommandItem
key={`${category}:${entry.parent ?? ""}:${entry.name}`}
value={`${entry.name} ${entry.title ?? ""} ${entry.parent ?? ""} ${entry.kind ?? ""}`}
onSelect={() => go(entry)}
className="min-h-11 md:min-h-8"
>
<Icon />
<span className="flex min-w-0 flex-col">
<span className="truncate">
{entry.title || entry.name}
</span>
{hint(entry) ? (
<span className="truncate text-xs text-muted-foreground">
{hint(entry)}
</span>
) : null}
</span>
</CommandItem>
))}
</CommandGroup>
)
})}
</>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
Start typing to search this installation.
</p>
)}
</CommandList>
</CommandDialog>
)
}
@@ -13,7 +13,7 @@ import {
import { libraryQueryOptions } from "./queries"
/**
* ⌘K: add a node, jump to another flow, or run the current one, without
* ⌘P: add a node, jump to another flow, or run the current one, without
* reaching for the dock.
*/
export function CommandPalette({
+1 -1
View File
@@ -157,7 +157,7 @@ export function FlowDock({
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent>Add a node (K)</TooltipContent>
<TooltipContent>Add a node (P)</TooltipContent>
</Tooltip>
<Separator
+5 -3
View File
@@ -1055,7 +1055,9 @@ function FlowEditorInner({
"mod+shift+z": () => step(false),
"mod+c": () => void copyNodes(),
"mod+v": pasteNodes,
"mod+k": () => setPaletteOpen((open) => !open),
// ⌘P, not ⌘K: the sidebar's global search owns that everywhere, and this
// palette is the canvas's own, narrower thing.
"mod+p": () => setPaletteOpen((open) => !open),
// Inside the code editor ⌘S applies that code, which the node panel
// owns; anywhere else on the canvas it puts the flow live.
"mod+s": (event) => {
@@ -1064,7 +1066,7 @@ function FlowEditorInner({
},
// Both stay reachable while typing: one is the editor's own save, the
// other is how you reach anything at all.
["mod+s", "mod+k"],
["mod+s", "mod+p"],
)
return (
@@ -1243,7 +1245,7 @@ function FlowEditorInner({
</span>
<p className="text-lg font-medium">This flow is empty</p>
<p className="max-w-xs text-sm text-muted-foreground">
Add a node to get started. Press K, or use the plus in the bar
Add a node to get started. Press P, or use the plus in the bar
below.
</p>
</div>
+52 -27
View File
@@ -8,12 +8,15 @@ import {
LayoutDashboard,
LogOut,
Package,
Search,
Server,
Settings,
Users,
Workflow,
} from "lucide-react"
import { useState } from "react"
import { GlobalSearch } from "@/components/Common/GlobalSearch"
import { Logo } from "@/components/Common/Logo"
import {
Sidebar,
@@ -24,6 +27,7 @@ import {
} 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"
/**
@@ -55,6 +59,12 @@ const baseItems: Item[] = [
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" }]
@@ -81,41 +91,56 @@ export function AppSidebar() {
onClick: () => window.open(DOCS_URL, "_blank", "noopener"),
}
const search: Item = {
icon: Search,
title: "Search",
onClick: () => setSearchOpen(true),
}
const footerItems: Item[] = portal
? [docs, { icon: Settings, title: "Settings", path: "/settings" }]
? [search, docs, { icon: Settings, title: "Settings", path: "/settings" }]
: [
search,
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. */}
<span className="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>
<>
{/* 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} />
</>
)
}
@@ -35,8 +35,15 @@ import {
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
type Search = { panels?: boolean }
export const Route = createFileRoute("/_layout/dashboards/")({
component: Dashboards,
// The panels dialog has no route of its own, so the address is how anything
// else — the global search among them — arrives at it.
validateSearch: (search: Record<string, unknown>): Search => ({
panels: search.panels === true || search.panels === "true" || undefined,
}),
})
function Dashboards() {
@@ -47,8 +54,12 @@ function Dashboards() {
const [name, setName] = useState("")
const [search, setSearch] = useState("")
const [dialogOpen, setDialogOpen] = useState(false)
const [panelsOpen, setPanelsOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
// Which screens exist is a question with an address, so anything can link to
// it — the global search lands a panel here.
const { panels: panelsOpen } = Route.useSearch()
const setPanelsOpen = (open: boolean) =>
navigate({ to: "/dashboards", search: open ? { panels: true } : {} })
const create = useMutation({
mutationFn: (dashboard: string) =>
@@ -110,7 +121,7 @@ function Dashboards() {
{/* Its own root rather than a nested one: which screens exist is a
different question from which dashboards do. */}
<Dialog open={panelsOpen} onOpenChange={setPanelsOpen}>
<Dialog open={panelsOpen ?? false} onOpenChange={setPanelsOpen}>
<PanelsDialog />
</Dialog>