{t("settings.hotkeys.title", "Keyboard Shortcuts")}
diff --git a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.css b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.css
new file mode 100644
index 0000000000..6920c37be4
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.css
@@ -0,0 +1,251 @@
+.super-search {
+ position: relative;
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: 560px;
+ --super-search-input-border: color-mix(
+ in srgb,
+ var(--c-text) 14%,
+ var(--c-border-subtle)
+ );
+ --super-search-input-outline: color-mix(
+ in srgb,
+ var(--c-text) 5%,
+ transparent
+ );
+}
+
+.super-search-input-row {
+ position: relative;
+}
+
+.super-search input {
+ box-sizing: border-box;
+ border: 1px solid var(--super-search-input-border);
+ border-radius: var(--radius-md);
+ background-color: var(--c-input-bg);
+ box-shadow: 0 0 0 1px var(--super-search-input-outline);
+ /* Tighter than the shared TextInput default (8px/14px) for a compact,
+ professional bar — height lands in line with the 28px action icons. */
+ padding-top: 6px;
+ padding-bottom: 6px;
+ font-size: 13px;
+}
+
+.super-search input:focus {
+ border-color: var(--c-primary);
+}
+
+/* Keyboard-shortcut hint pinned to the right of the input. */
+.super-search-kbd {
+ position: absolute;
+ right: 0.5rem;
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+ font-size: 0.7rem;
+ line-height: 1;
+ padding: 0.15rem 0.35rem;
+ border-radius: var(--radius-sm);
+ color: var(--c-text-subtle);
+ background: var(--c-hover);
+ border: 1px solid var(--c-border-subtle);
+ /* Tiny breathing room between the ⌘ glyph and the K. */
+ letter-spacing: 0.06em;
+}
+
+/* Portalled to ; coords are set inline from the input's viewport rect. */
+.super-search-dropdown {
+ position: fixed;
+ z-index: 1000;
+ max-height: 60vh;
+ overflow-y: auto;
+ padding: 0.35rem;
+ background: var(--c-surface);
+ border: 1px solid var(--c-border-subtle);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
+}
+
+.super-search-empty {
+ padding: 0.75rem 0.6rem;
+ font-size: 0.85rem;
+ color: var(--c-text-subtle);
+}
+
+.super-search-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ padding: 0.35rem 0.35rem 0.5rem;
+ margin-bottom: 0.15rem;
+ border-bottom: 1px solid var(--c-border-subtle);
+}
+
+.super-search-filters .sui-chip {
+ user-select: none;
+}
+
+.super-search-section + .super-search-section {
+ margin-top: 0.5rem;
+}
+
+.super-search-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+}
+
+.super-search-section-header {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-left: -0.65rem;
+ padding: 0.15rem 0.25rem 0.05rem 0;
+}
+
+.super-search-section-header::after {
+ content: "";
+ flex: 1 1 auto;
+ min-width: 1.5rem;
+ height: 1px;
+ background: color-mix(in srgb, var(--c-text) 12%, var(--c-border-subtle));
+}
+
+.super-search-section-body {
+ padding: 0.05rem 0.05rem 0.2rem 0;
+}
+
+.super-search-section-toggle.mantine-Button-root {
+ --button-height: auto;
+ --button-padding-x: 0;
+ min-height: 0;
+ padding-block: 0;
+ flex: 0 0 auto;
+}
+
+.super-search-section-toggle.mantine-Button-root:hover,
+.super-search-section-toggle.mantine-Button-root:focus-visible {
+ background: transparent;
+}
+
+.super-search-section-toggle:hover .super-search-section-label {
+ color: var(--c-text);
+}
+
+.super-search-section-toggle .mantine-Button-inner {
+ align-items: center;
+}
+
+/* Per-group "show N more" / "show less" toggle. */
+.super-search-show-more.mantine-Button-root {
+ --button-height: auto;
+ min-height: 0;
+ padding: 0.25rem 0.5rem 0.35rem;
+ font-size: 0.78rem;
+ color: var(--c-text-subtle);
+}
+.super-search-show-more.mantine-Button-root:hover,
+.super-search-show-more.mantine-Button-root:focus-visible {
+ background: transparent;
+ color: var(--c-text);
+}
+
+.super-search-section-toggle .mantine-Button-label {
+ text-align: left;
+}
+
+.super-search-section-toggle .mantine-Button-section {
+ margin-inline-start: 0.2rem;
+}
+
+.super-search-section-chevron {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 1rem;
+ height: 1rem;
+ color: var(--c-text-subtle);
+ transition:
+ transform 140ms ease,
+ color 140ms ease;
+}
+
+.super-search-section-toggle:hover .super-search-section-chevron {
+ color: var(--c-text-muted);
+}
+
+.super-search-section-chevron--collapsed {
+ transform: rotate(-90deg);
+}
+
+.super-search-group + .super-search-group {
+ margin-top: 0.45rem;
+ padding-top: 0.35rem;
+ border-top: 1px solid color-mix(in srgb, var(--c-text) 8%, transparent);
+}
+
+.super-search-section-label {
+ padding: 0.18rem 0;
+ font-size: 0.74rem;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--c-text-muted);
+}
+
+.super-search-group-label {
+ padding: 0.35rem 0.15rem 0.18rem 0.85rem;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--c-text-subtle);
+}
+
+.super-search-group-results {
+ padding-left: 1.25rem;
+}
+
+.super-search-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.45rem 0.5rem;
+ border: none;
+ border-radius: var(--radius-md);
+ background: transparent;
+ text-align: left;
+ cursor: pointer;
+ color: var(--c-text);
+}
+
+.super-search-item.active,
+.super-search-item:hover {
+ background: var(--c-hover);
+}
+
+.super-search-item-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 1.4rem;
+ height: 1.4rem;
+ color: var(--c-text-muted);
+}
+
+.super-search-item-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.super-search-item-title {
+ font-size: 0.875rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.test.tsx b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.test.tsx
new file mode 100644
index 0000000000..f77984f090
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.test.tsx
@@ -0,0 +1,537 @@
+import { useEffect, useState } from "react";
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
+
+vi.mock("@app/hooks/useSuperSearch", () => ({
+ useSuperSearch: vi.fn(() => ({
+ groups: [],
+ flatResults: [],
+ loadingFiles: false,
+ })),
+}));
+
+vi.mock("@app/utils/hotkeys", () => ({
+ isMacLike: () => false,
+}));
+
+import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
+import type {
+ SuperSearchQueryOptions,
+ SuperSearchScope,
+} from "@app/hooks/useSuperSearch";
+
+interface TestResult {
+ key: string;
+ group: string;
+ title: string;
+ subtitle?: string;
+ score: number;
+ onSelect: () => void | Promise
;
+}
+
+interface TestGroup {
+ id: string;
+ label: string;
+ sectionLabel?: string;
+ results: TestResult[];
+}
+
+interface TestUseResultsResult {
+ groups: TestGroup[];
+ flatResults: TestResult[];
+ loadingFiles: boolean;
+}
+
+type TestUseResultsHook = (
+ query: string,
+ active: boolean,
+ options?: SuperSearchQueryOptions,
+) => TestUseResultsResult;
+
+const TEST_SCOPES: SuperSearchScope[] = [
+ {
+ id: "portal-policies",
+ label: "Policies",
+ aliases: ["policy", "policies"],
+ },
+ {
+ id: "portal-pipelines",
+ label: "Pipelines",
+ aliases: ["pipeline", "pipelines"],
+ },
+];
+
+function makeResult(
+ key: string,
+ title: string,
+ onSelect = vi.fn(),
+): TestResult {
+ return {
+ key,
+ group: "tools",
+ title,
+ score: 100,
+ onSelect,
+ };
+}
+
+function renderSearch(
+ useResults?: TestUseResultsHook,
+ scopes?: readonly SuperSearchScope[],
+) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("SuperSearch", () => {
+ beforeEach(() => {
+ vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: 320,
+ bottom: 40,
+ width: 320,
+ height: 40,
+ toJSON: () => "",
+ } as DOMRect);
+
+ Object.defineProperty(Element.prototype, "scrollIntoView", {
+ value: vi.fn(),
+ writable: true,
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("opens from Ctrl+K based on e.code and exposes combobox wiring", async () => {
+ const useResults: TestUseResultsHook = () => ({
+ groups: [],
+ flatResults: [],
+ loadingFiles: false,
+ });
+
+ renderSearch(useResults);
+
+ const input = screen.getByRole("combobox");
+ expect(input).toHaveAttribute("aria-expanded", "false");
+
+ fireEvent.keyDown(document.body, {
+ ctrlKey: true,
+ code: "KeyK",
+ key: "x",
+ });
+
+ await waitFor(() => {
+ expect(input).toHaveFocus();
+ expect(input).toHaveAttribute("aria-expanded", "true");
+ });
+
+ expect(input).toHaveAttribute("aria-controls", "test-super-search-listbox");
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
+ });
+
+ it("keeps the highlight on the same result key when async results reorder", async () => {
+ const alphaSelect = vi.fn();
+ const betaSelect = vi.fn();
+
+ const useAsyncResults: TestUseResultsHook = (query, active) => {
+ const [swapped, setSwapped] = useState(false);
+
+ useEffect(() => {
+ if (!active || !query.trim()) {
+ setSwapped(false);
+ return;
+ }
+
+ const timeoutId = window.setTimeout(() => setSwapped(true), 50);
+ return () => window.clearTimeout(timeoutId);
+ }, [active, query]);
+
+ const results = swapped
+ ? [
+ makeResult("beta", "Beta", betaSelect),
+ makeResult("alpha", "Alpha", alphaSelect),
+ ]
+ : [
+ makeResult("alpha", "Alpha", alphaSelect),
+ makeResult("beta", "Beta", betaSelect),
+ ];
+ const groups = query.trim()
+ ? [{ id: "tools", label: "Tools", results }]
+ : [];
+
+ return {
+ groups,
+ flatResults: groups.flatMap((group) => group.results),
+ loadingFiles: false,
+ };
+ };
+
+ renderSearch(useAsyncResults);
+
+ const input = screen.getByRole("combobox");
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: "a" } });
+
+ await screen.findByText("Alpha");
+
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+ expect(input).toHaveAttribute(
+ "aria-activedescendant",
+ "test-super-search-option-1",
+ );
+
+ await waitFor(() => {
+ expect(input).toHaveAttribute(
+ "aria-activedescendant",
+ "test-super-search-option-0",
+ );
+ });
+
+ expect(screen.getByText("Beta").closest('[role="option"]')).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ expect(betaSelect).toHaveBeenCalledTimes(1);
+ expect(alphaSelect).not.toHaveBeenCalled();
+ });
+
+ it("supports multi-select scope chips and clears manual filters on close", async () => {
+ const useScopedResults: TestUseResultsHook = (query, active, options) => {
+ if (!active || !query.trim()) {
+ return { groups: [], flatResults: [], loadingFiles: false };
+ }
+
+ const enabledScopes = new Set(options?.scopeIds ?? []);
+ const allGroups: TestGroup[] = [
+ {
+ id: "portal-policies",
+ label: "Policies",
+ results: [makeResult("policy", "Policy Match")],
+ },
+ {
+ id: "portal-pipelines",
+ label: "Pipelines",
+ results: [makeResult("pipeline", "Pipeline Match")],
+ },
+ ];
+ const groups =
+ enabledScopes.size === 0
+ ? allGroups
+ : allGroups.filter((group) => enabledScopes.has(group.id));
+
+ return {
+ groups,
+ flatResults: groups.flatMap((group) => group.results),
+ loadingFiles: false,
+ };
+ };
+
+ renderSearch(useScopedResults, TEST_SCOPES);
+
+ const input = screen.getByRole("combobox");
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: "invoice" } });
+
+ await screen.findByText("Policy Match");
+ expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "Policies" }));
+ await waitFor(() => {
+ expect(screen.getByText("Policy Match")).toBeInTheDocument();
+ expect(screen.queryByText("Pipeline Match")).not.toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Pipelines" }));
+ await waitFor(() => {
+ expect(screen.getByText("Policy Match")).toBeInTheDocument();
+ expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
+ });
+
+ fireEvent.mouseDown(document.body);
+ await waitFor(() => {
+ expect(input).toHaveAttribute("aria-expanded", "false");
+ });
+
+ fireEvent.focus(input);
+ await waitFor(() => {
+ expect(screen.getByText("Policy Match")).toBeInTheDocument();
+ expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
+ });
+ });
+
+ it("maps scope prefixes onto active chips and lets the chip remove them", async () => {
+ const useScopedResults: TestUseResultsHook = (query, active, options) => {
+ if (!active || !query.trim()) {
+ return { groups: [], flatResults: [], loadingFiles: false };
+ }
+
+ const enabledScopes = new Set(options?.scopeIds ?? []);
+ const groups: TestGroup[] = [
+ {
+ id: "portal-policies",
+ label: "Policies",
+ results: [makeResult("policy", "Policy Match")],
+ },
+ {
+ id: "portal-pipelines",
+ label: "Pipelines",
+ results: [makeResult("pipeline", "Pipeline Match")],
+ },
+ ].filter(
+ (group) => enabledScopes.size === 0 || enabledScopes.has(group.id),
+ );
+
+ return {
+ groups,
+ flatResults: groups.flatMap((group) => group.results),
+ loadingFiles: false,
+ };
+ };
+
+ renderSearch(useScopedResults, TEST_SCOPES);
+
+ const input = screen.getByRole("combobox");
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: "policy: invoice" } });
+
+ await waitFor(() => {
+ expect(screen.getByText("Policy Match")).toBeInTheDocument();
+ expect(screen.queryByText("Pipeline Match")).not.toBeInTheDocument();
+ });
+
+ const policyChip = screen.getByRole("button", { name: "Policies" });
+ expect(policyChip).toHaveAttribute("aria-pressed", "true");
+
+ fireEvent.click(policyChip);
+
+ await waitFor(() => {
+ expect(input).toHaveValue("invoice");
+ expect(screen.getByText("Policy Match")).toBeInTheDocument();
+ expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
+ });
+ });
+
+ it("shows no-results below scoped filters before typing", async () => {
+ const useResults: TestUseResultsHook = () => ({
+ groups: [],
+ flatResults: [],
+ loadingFiles: false,
+ });
+
+ renderSearch(useResults, TEST_SCOPES);
+
+ const input = screen.getByRole("combobox");
+ fireEvent.focus(input);
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: "Policies" }),
+ ).toBeInTheDocument();
+ });
+
+ expect(screen.queryByText("Type to search")).not.toBeInTheDocument();
+ expect(screen.getByText("search.noResults")).toBeInTheDocument();
+ });
+
+ it("renders shared section headers once for consecutive groups", async () => {
+ const useResults: TestUseResultsHook = (query) => {
+ if (!query.trim()) {
+ return { groups: [], flatResults: [], loadingFiles: false };
+ }
+
+ const groups: TestGroup[] = [
+ {
+ id: "portal-users",
+ label: "Users",
+ sectionLabel: "Processor",
+ results: [makeResult("user", "Alice")],
+ },
+ {
+ id: "portal-policies",
+ label: "Policies",
+ sectionLabel: "Processor",
+ results: [makeResult("policy", "Security Policy")],
+ },
+ {
+ id: "tools",
+ label: "Tools",
+ sectionLabel: "Editor",
+ results: [makeResult("tool", "Merge PDFs")],
+ },
+ ];
+
+ return {
+ groups,
+ flatResults: groups.flatMap((group) => group.results),
+ loadingFiles: false,
+ };
+ };
+
+ renderSearch(useResults);
+
+ const input = screen.getByRole("combobox");
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: "a" } });
+
+ await screen.findByText("Alice");
+
+ expect(screen.getAllByText("Processor")).toHaveLength(1);
+ expect(screen.getAllByText("Editor")).toHaveLength(1);
+ });
+
+ it("caps a large group behind show-more and expands/collapses it", async () => {
+ const titles = Array.from({ length: 8 }, (_, i) => `Tool ${i + 1}`);
+ const useResults: TestUseResultsHook = (query) => {
+ if (!query.trim()) {
+ return { groups: [], flatResults: [], loadingFiles: false };
+ }
+ const groups: TestGroup[] = [
+ {
+ id: "tools",
+ label: "Tools",
+ results: titles.map((title, i) => makeResult(`tool-${i}`, title)),
+ },
+ ];
+ return {
+ groups,
+ flatResults: groups.flatMap((group) => group.results),
+ loadingFiles: false,
+ };
+ };
+
+ renderSearch(useResults);
+
+ const input = screen.getByRole("combobox");
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: "tool" } });
+
+ await screen.findByText("Tool 1");
+ expect(screen.getByText("Tool 5")).toBeInTheDocument();
+ expect(screen.queryByText("Tool 6")).not.toBeInTheDocument();
+
+ const toggle = screen.getByRole("button", {
+ name: /superSearch\.showMore/,
+ });
+ fireEvent.click(toggle);
+
+ await screen.findByText("Tool 8");
+ expect(
+ screen.getByRole("button", { name: /superSearch\.showLess/ }),
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /superSearch\.showLess/ }),
+ );
+ await waitFor(() => {
+ expect(screen.queryByText("Tool 6")).not.toBeInTheDocument();
+ expect(screen.getByText("Tool 5")).toBeInTheDocument();
+ });
+ });
+
+ it("shows a cap+1 group in full with no show-more toggle", async () => {
+ const useResults: TestUseResultsHook = (query) => {
+ if (!query.trim()) {
+ return { groups: [], flatResults: [], loadingFiles: false };
+ }
+ const groups: TestGroup[] = [
+ {
+ id: "tools",
+ label: "Tools",
+ results: Array.from({ length: 6 }, (_, i) =>
+ makeResult(`tool-${i}`, `Tool ${i + 1}`),
+ ),
+ },
+ ];
+ return {
+ groups,
+ flatResults: groups.flatMap((group) => group.results),
+ loadingFiles: false,
+ };
+ };
+
+ renderSearch(useResults);
+
+ const input = screen.getByRole("combobox");
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: "tool" } });
+
+ await screen.findByText("Tool 6");
+ expect(
+ screen.queryByRole("button", { name: /superSearch\.show/ }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("lets a section collapse and reopen without hiding the other section", async () => {
+ const useResults: TestUseResultsHook = (query) => {
+ if (!query.trim()) {
+ return { groups: [], flatResults: [], loadingFiles: false };
+ }
+
+ const groups: TestGroup[] = [
+ {
+ id: "portal-users",
+ label: "Users",
+ sectionLabel: "Processor",
+ results: [makeResult("user", "Alice")],
+ },
+ {
+ id: "tools",
+ label: "Tools",
+ sectionLabel: "Editor",
+ results: [makeResult("tool", "Merge PDFs")],
+ },
+ ];
+
+ return {
+ groups,
+ flatResults: groups.flatMap((group) => group.results),
+ loadingFiles: false,
+ };
+ };
+
+ renderSearch(useResults);
+
+ const input = screen.getByRole("combobox");
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: "a" } });
+
+ await screen.findByText("Alice");
+ const processorToggle = screen.getByRole("button", { name: "Processor" });
+
+ expect(processorToggle).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ expect(screen.getByText("Merge PDFs")).toBeInTheDocument();
+
+ fireEvent.click(processorToggle);
+
+ await waitFor(() => {
+ expect(processorToggle).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByText("Alice")).not.toBeInTheDocument();
+ expect(screen.getByText("Merge PDFs")).toBeInTheDocument();
+ });
+
+ fireEvent.click(processorToggle);
+
+ await waitFor(() => {
+ expect(processorToggle).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx
new file mode 100644
index 0000000000..6afcdfff82
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx
@@ -0,0 +1,656 @@
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { createPortal } from "react-dom";
+import { useTranslation } from "react-i18next";
+import { Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { Chip } from "@app/ui/Chip";
+import { TextInput } from "@app/components/shared/TextInput";
+import LocalIcon from "@app/components/shared/LocalIcon";
+import { isMacLike } from "@app/utils/hotkeys";
+import {
+ useSuperSearch,
+ SuperSearchResult,
+ type SuperSearchQueryOptions,
+ type SuperSearchScope,
+ type UseSuperSearchResult,
+} from "@app/hooks/useSuperSearch";
+import {
+ parseSuperSearchQuery,
+ rebuildSuperSearchQuery,
+} from "@app/components/shared/superSearch/superSearchFilters";
+import "@app/components/shared/superSearch/SuperSearch.css";
+
+/** Rows shown per group before a "show more" toggle reveals the rest. */
+const COLLAPSED_GROUP_SIZE = 5;
+
+interface DropdownRect {
+ top: number;
+ left: number;
+ width: number;
+}
+
+interface SuperSearchSection {
+ key: string;
+ label?: string;
+ groups: UseSuperSearchResult["groups"];
+}
+
+interface SuperSearchProps {
+ /**
+ * Results provider, called as a hook — it MUST be referentially stable for
+ * the component's lifetime. Defaults to the editor's files/tools/settings/
+ * Processor provider; the portal passes its own destinations provider.
+ */
+ useResults?: (
+ query: string,
+ active: boolean,
+ options?: SuperSearchQueryOptions,
+ ) => UseSuperSearchResult;
+ /**
+ * DOM id for the input — external focus helpers target the default. A host
+ * whose bar can coexist with another instance must pass a distinct id.
+ */
+ inputId?: string;
+ /** Optional scope chips + `scope:` prefixes for host-specific filters. */
+ scopes?: readonly SuperSearchScope[];
+ /** Dropdown width floor — the results panel needs more room than the
+ * compact input. Defaults shared by every bar; override per host if a
+ * layout can't fit it. */
+ dropdownMinWidth?: number;
+ /**
+ * Extra class for the dropdown. The dropdown is portalled to , so a
+ * host's descendant selectors can't reach it — this is the styling hook.
+ */
+ dropdownClassName?: string;
+}
+
+/**
+ * Global "super search": a single entry point that searches across the host
+ * app's destinations from a persistent bar. The results hang in a dropdown
+ * directly below the input; Cmd/Ctrl+K focuses and opens it.
+ *
+ * The dropdown is portalled to : the host bar's inner wrapper may set
+ * `overflow: hidden`, which would otherwise clip it.
+ */
+export default function SuperSearch({
+ useResults = useSuperSearch,
+ inputId = "super-search-input",
+ scopes = [],
+ dropdownMinWidth = 760,
+ dropdownClassName,
+}: SuperSearchProps = {}) {
+ const { t } = useTranslation();
+ const [query, setQuery] = useState("");
+ const [open, setOpen] = useState(false);
+ const [highlight, setHighlight] = useState(0);
+ const [rect, setRect] = useState(null);
+ const [manualScopeIds, setManualScopeIds] = useState([]);
+ const [collapsedSectionKeys, setCollapsedSectionKeys] = useState(
+ [],
+ );
+ // Groups the user has expanded past the initial per-group cap ("show more").
+ const [expandedGroupKeys, setExpandedGroupKeys] = useState([]);
+
+ const inputRef = useRef(null);
+ const containerRef = useRef(null);
+ const dropdownRef = useRef(null);
+ // The key of the highlighted result, so async pop-in (entities, file stubs)
+ // can't silently shift the highlight onto a different row.
+ const highlightKeyRef = useRef(null);
+
+ // A hook received as a prop is invisible to the rules-of-hooks lint; pinning
+ // the first value makes swapping it mid-life structurally impossible.
+ const useResultsHook = useRef(useResults).current;
+ const parsedQuery = useMemo(
+ () => parseSuperSearchQuery(query, scopes),
+ [query, scopes],
+ );
+ const effectiveScopeIds = useMemo(
+ () =>
+ Array.from(new Set([...parsedQuery.prefixedScopeIds, ...manualScopeIds])),
+ [manualScopeIds, parsedQuery.prefixedScopeIds],
+ );
+ const activeScopeIds = useMemo(
+ () => new Set(effectiveScopeIds),
+ [effectiveScopeIds],
+ );
+ const { groups, flatResults, loadingFiles } = useResultsHook(
+ parsedQuery.query,
+ open,
+ effectiveScopeIds.length > 0 ? { scopeIds: effectiveScopeIds } : undefined,
+ );
+
+ const trimmed = parsedQuery.query.trim();
+ const hasQuery = trimmed.length > 0;
+
+ const listboxId = `${inputId}-listbox`;
+ const optionId = useCallback(
+ (index: number) => `${inputId}-option-${index}`,
+ [inputId],
+ );
+
+ const sections = useMemo(() => {
+ const out: SuperSearchSection[] = [];
+
+ for (const group of groups) {
+ const current = out[out.length - 1];
+ if (current && current.label === group.sectionLabel) {
+ current.groups.push(group);
+ continue;
+ }
+
+ out.push({
+ key: group.sectionLabel ?? group.id,
+ label: group.sectionLabel,
+ groups: [group],
+ });
+ }
+
+ return out;
+ }, [groups]);
+
+ const visibleSections = useMemo(
+ () =>
+ sections.map((section) => ({
+ ...section,
+ collapsed:
+ section.label != null && collapsedSectionKeys.includes(section.key),
+ })),
+ [collapsedSectionKeys, sections],
+ );
+
+ // How many rows a group currently shows: its full set when expanded, else
+ // the initial cap. A group at exactly cap+1 just shows the extra row rather
+ // than a "show 1 more" toggle.
+ const visibleCountFor = useCallback(
+ (group: UseSuperSearchResult["groups"][number]) =>
+ expandedGroupKeys.includes(group.id) ||
+ group.results.length <= COLLAPSED_GROUP_SIZE + 1
+ ? group.results.length
+ : COLLAPSED_GROUP_SIZE,
+ [expandedGroupKeys],
+ );
+
+ const visibleFlatResults = useMemo(
+ () =>
+ visibleSections.flatMap((section) =>
+ section.collapsed
+ ? []
+ : section.groups.flatMap((group) =>
+ group.results.slice(0, visibleCountFor(group)),
+ ),
+ ),
+ [visibleSections, visibleCountFor],
+ );
+ // Row index per result key — the render below would otherwise indexOf()
+ // every row against the flat list (quadratic in visible results).
+ const flatIndexByKey = useMemo(
+ () => new Map(visibleFlatResults.map((result, i) => [result.key, i])),
+ [visibleFlatResults],
+ );
+
+ const moveHighlight = useCallback(
+ (index: number) => {
+ setHighlight(index);
+ highlightKeyRef.current = visibleFlatResults[index]?.key ?? null;
+ },
+ [visibleFlatResults],
+ );
+
+ // When results change, follow the highlighted result to its new index; if
+ // it's gone, reset to the top.
+ useEffect(() => {
+ const key = highlightKeyRef.current;
+ if (key) {
+ const index = visibleFlatResults.findIndex((r) => r.key === key);
+ if (index >= 0) {
+ setHighlight(index);
+ return;
+ }
+ }
+ highlightKeyRef.current = null;
+ setHighlight(0);
+ }, [visibleFlatResults]);
+
+ const close = useCallback(() => {
+ setOpen(false);
+ setHighlight(0);
+ setManualScopeIds([]);
+ setCollapsedSectionKeys([]);
+ setExpandedGroupKeys([]);
+ setQuery((current) => parseSuperSearchQuery(current, scopes).query);
+ }, [scopes]);
+
+ // Position the portalled dropdown directly under the input while open.
+ const updateRect = useCallback(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const r = el.getBoundingClientRect();
+ const viewportPadding = 8;
+ const width = Math.min(
+ Math.max(r.width, dropdownMinWidth ?? r.width),
+ window.innerWidth - viewportPadding * 2,
+ );
+ const centeredLeft = r.left - (width - r.width) / 2;
+ const left = Math.min(
+ Math.max(viewportPadding, centeredLeft),
+ window.innerWidth - width - viewportPadding,
+ );
+ setRect({ top: r.bottom + 6, left, width });
+ }, [dropdownMinWidth]);
+
+ useLayoutEffect(() => {
+ if (!open) return;
+ updateRect();
+ window.addEventListener("resize", updateRect);
+ window.addEventListener("scroll", updateRect, true);
+ return () => {
+ window.removeEventListener("resize", updateRect);
+ window.removeEventListener("scroll", updateRect, true);
+ };
+ }, [open, updateRect]);
+
+ const selectResult = useCallback(
+ (result: SuperSearchResult | undefined) => {
+ if (!result) return;
+ void result.onSelect();
+ setQuery("");
+ close();
+ inputRef.current?.blur();
+ },
+ [close],
+ );
+
+ // Global Cmd/Ctrl+K to focus + open the search. Matched on e.code so it
+ // works on non-Latin keyboard layouts, where e.key isn't "k".
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ // Accept both modifiers everywhere: Macs have a Control key too, and
+ // muscle memory from other platforms expects Ctrl+K to keep working.
+ const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey;
+ if (!combo || e.code !== "KeyK") return;
+ // Leave the shortcut alone while a modal owns the screen — focusing an
+ // input underneath the overlay would strand keyboard focus. Modals that
+ // want to cede to the search (the settings modal does) close themselves
+ // and dispatch "superSearch:focus" instead.
+ const target = e.target as HTMLElement | null;
+ if (target?.closest('[role="dialog"]')) return;
+ e.preventDefault();
+ setOpen(true);
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ };
+ // Focus handover from a closing dialog. Only the on-screen instance
+ // responds (offsetParent is null while display:none / unmounted hosts),
+ // and focus waits two frames so the dialog's own return-focus runs first.
+ const onFocusRequest = () => {
+ const input = inputRef.current;
+ if (!input || input.offsetParent === null) return;
+ setOpen(true);
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() => {
+ input.focus();
+ input.select();
+ }),
+ );
+ };
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener("superSearch:focus", onFocusRequest);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("superSearch:focus", onFocusRequest);
+ };
+ }, []);
+
+ // Keep the highlighted row visible when keyboard navigation moves it past
+ // the dropdown's scroll fold.
+ useEffect(() => {
+ if (!open) return;
+ document
+ .getElementById(optionId(highlight))
+ ?.scrollIntoView({ block: "nearest" });
+ }, [open, highlight, optionId]);
+
+ // Close on click outside the input or the (portalled) dropdown.
+ useEffect(() => {
+ if (!open) return;
+ const onMouseDown = (e: MouseEvent) => {
+ const target = e.target as Node;
+ if (
+ containerRef.current?.contains(target) ||
+ dropdownRef.current?.contains(target)
+ ) {
+ return;
+ }
+ close();
+ };
+ document.addEventListener("mousedown", onMouseDown);
+ return () => document.removeEventListener("mousedown", onMouseDown);
+ }, [open, close]);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ // During IME composition (CJK input), Enter commits the composition and
+ // arrows pick candidates — those keystrokes are not for us.
+ if (e.nativeEvent.isComposing) return;
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ if (!open) setOpen(true);
+ moveHighlight(
+ visibleFlatResults.length === 0
+ ? 0
+ : (highlight + 1) % visibleFlatResults.length,
+ );
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ moveHighlight(
+ visibleFlatResults.length === 0
+ ? 0
+ : (highlight - 1 + visibleFlatResults.length) %
+ visibleFlatResults.length,
+ );
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ selectResult(visibleFlatResults[highlight]);
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ // Consume the event: hosts with their own window-level Escape handling
+ // (e.g. the Files page closing itself) must not also act on this press —
+ // especially since the blur below makes their focus guards pass.
+ e.stopPropagation();
+ if (hasQuery) {
+ setQuery("");
+ } else {
+ close();
+ inputRef.current?.blur();
+ }
+ }
+ };
+
+ const shortcutHint = useMemo(() => (isMacLike() ? "⌘K" : "Ctrl+K"), []);
+
+ const toggleScope = useCallback(
+ (scopeId: string) => {
+ if (scopeId === "__all__") {
+ setManualScopeIds([]);
+ if (parsedQuery.prefixTokens.length > 0) {
+ setQuery(parsedQuery.query);
+ }
+ inputRef.current?.focus();
+ return;
+ }
+
+ const hasPrefix = parsedQuery.prefixedScopeIds.includes(scopeId);
+ if (hasPrefix) {
+ setManualScopeIds((current) => current.filter((id) => id !== scopeId));
+ const remainingPrefixScopeIds = new Set(
+ parsedQuery.prefixedScopeIds.filter((id) => id !== scopeId),
+ );
+ setQuery(rebuildSuperSearchQuery(parsedQuery, remainingPrefixScopeIds));
+ inputRef.current?.focus();
+ return;
+ }
+
+ setManualScopeIds((current) =>
+ current.includes(scopeId)
+ ? current.filter((id) => id !== scopeId)
+ : [...current, scopeId],
+ );
+ inputRef.current?.focus();
+ },
+ [parsedQuery],
+ );
+
+ const showNoResults =
+ open &&
+ !loadingFiles &&
+ flatResults.length === 0 &&
+ (hasQuery || scopes.length > 0);
+
+ const toggleSection = useCallback((sectionKey: string) => {
+ setCollapsedSectionKeys((current) =>
+ current.includes(sectionKey)
+ ? current.filter((key) => key !== sectionKey)
+ : [...current, sectionKey],
+ );
+ inputRef.current?.focus();
+ }, []);
+
+ const toggleGroupExpanded = useCallback((groupId: string) => {
+ setExpandedGroupKeys((current) =>
+ current.includes(groupId)
+ ? current.filter((id) => id !== groupId)
+ : [...current, groupId],
+ );
+ inputRef.current?.focus();
+ }, []);
+
+ const scopeFilters =
+ scopes.length > 0 ? (
+
+ toggleScope("__all__")}
+ >
+ {t("superSearch.all", "All")}
+
+ {scopes.map((scope) => {
+ const active = activeScopeIds.has(scope.id);
+ return (
+ toggleScope(scope.id)}
+ >
+ {scope.label}
+
+ );
+ })}
+
+ ) : null;
+
+ const dropdown =
+ open && rect ? (
+ e.preventDefault()}
+ >
+ {scopeFilters}
+
+ {!hasQuery && scopes.length === 0 && (
+
+ {t("superSearch.hint", "Type to search")}
+
+ )}
+
+ {showNoResults && (
+
+ {t("search.noResults", "No results found")}
+
+ )}
+
+ {hasQuery &&
+ visibleSections.map((section) => (
+
+ {section.label && (
+
+
+
+ )}
+ {!section.collapsed && (
+
+ {section.groups.map((group) => {
+ const shownCount = visibleCountFor(group);
+ const hiddenCount = group.results.length - shownCount;
+ // Groups within one row of the cap always show everything
+ // (visibleCountFor's cap+1 exception) — no toggle for them.
+ const expandable =
+ group.results.length > COLLAPSED_GROUP_SIZE + 1;
+ return (
+
+ {/* A lone group repeating its section's name reads as a
+ duplicate header — the section title covers it. */}
+ {group.label !== section.label && (
+
+ {group.label}
+
+ )}
+
+ {group.results.slice(0, shownCount).map((result) => {
+ const index = flatIndexByKey.get(result.key) ?? -1;
+ const active = index === highlight;
+ return (
+
+ );
+ })}
+
+ {expandable && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ ))}
+
+ ) : null;
+
+ return (
+
+
+
+ }
+ autoComplete="off"
+ role="combobox"
+ aria-label={t("superSearch.ariaLabel", "Super search")}
+ aria-expanded={open}
+ aria-controls={open ? listboxId : undefined}
+ aria-activedescendant={
+ open && visibleFlatResults[highlight]
+ ? optionId(highlight)
+ : undefined
+ }
+ aria-autocomplete="list"
+ onFocus={() => setOpen(true)}
+ />
+ {!open && !hasQuery && (
+
+ {shortcutHint}
+
+ )}
+
+ {dropdown && createPortal(dropdown, document.body)}
+
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/superSearch/superSearchFilters.test.ts b/frontend/editor/src/core/components/shared/superSearch/superSearchFilters.test.ts
new file mode 100644
index 0000000000..a8637952f9
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/superSearch/superSearchFilters.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import {
+ parseSuperSearchQuery,
+ rebuildSuperSearchQuery,
+} from "@app/components/shared/superSearch/superSearchFilters";
+import type { SuperSearchScope } from "@app/hooks/useSuperSearch";
+
+const SCOPES: SuperSearchScope[] = [
+ { id: "portal-policies", label: "Policies", aliases: ["policy", "policies"] },
+ { id: "tools", label: "Tools", aliases: ["tool", "tools"] },
+];
+
+describe("parseSuperSearchQuery", () => {
+ it("returns the trimmed query untouched when no scopes exist", () => {
+ const parsed = parseSuperSearchQuery(" policy: invoice ", []);
+ expect(parsed).toEqual({
+ query: "policy: invoice",
+ prefixTokens: [],
+ prefixedScopeIds: [],
+ });
+ });
+
+ it("maps a known prefix to its scope and strips it from the query", () => {
+ const parsed = parseSuperSearchQuery("policy: invoice", SCOPES);
+ expect(parsed.query).toBe("invoice");
+ expect(parsed.prefixedScopeIds).toEqual(["portal-policies"]);
+ expect(parsed.prefixTokens).toEqual([
+ { scopeId: "portal-policies", token: "policy:" },
+ ]);
+ });
+
+ it("accepts chained prefixes and aliases case-insensitively", () => {
+ const parsed = parseSuperSearchQuery("Policy:TOOLS: merge", SCOPES);
+ expect(parsed.query).toBe("merge");
+ expect(parsed.prefixedScopeIds).toEqual(["portal-policies", "tools"]);
+ });
+
+ it("dedupes repeated prefixes for the same scope", () => {
+ const parsed = parseSuperSearchQuery("policy: policies: invoice", SCOPES);
+ expect(parsed.prefixedScopeIds).toEqual(["portal-policies"]);
+ expect(parsed.prefixTokens).toHaveLength(2);
+ });
+
+ it("stops at the first unknown token so colon text stays in the query", () => {
+ const parsed = parseSuperSearchQuery("chapter: 1 policy: x", SCOPES);
+ expect(parsed.query).toBe("chapter: 1 policy: x");
+ expect(parsed.prefixedScopeIds).toEqual([]);
+ });
+
+ it("leaves colons after the first word alone", () => {
+ const parsed = parseSuperSearchQuery("policy: name: value", SCOPES);
+ expect(parsed.query).toBe("name: value");
+ expect(parsed.prefixedScopeIds).toEqual(["portal-policies"]);
+ });
+});
+
+describe("rebuildSuperSearchQuery", () => {
+ it("keeps only the requested scopes' tokens, in original order", () => {
+ const parsed = parseSuperSearchQuery("policy: tools: merge", SCOPES);
+ expect(rebuildSuperSearchQuery(parsed, new Set(["tools"]))).toBe(
+ "tools: merge",
+ );
+ expect(rebuildSuperSearchQuery(parsed, new Set())).toBe("merge");
+ expect(
+ rebuildSuperSearchQuery(parsed, new Set(["portal-policies", "tools"])),
+ ).toBe("policy: tools: merge");
+ });
+
+ it("drops the trailing space when the free text is empty", () => {
+ const parsed = parseSuperSearchQuery("policy:", SCOPES);
+ expect(rebuildSuperSearchQuery(parsed, new Set())).toBe("");
+ });
+});
diff --git a/frontend/editor/src/core/components/shared/superSearch/superSearchFilters.ts b/frontend/editor/src/core/components/shared/superSearch/superSearchFilters.ts
new file mode 100644
index 0000000000..58b066c6f9
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/superSearch/superSearchFilters.ts
@@ -0,0 +1,77 @@
+import type { SuperSearchScope } from "@app/hooks/useSuperSearch";
+
+export interface SuperSearchPrefixToken {
+ scopeId: string;
+ token: string;
+}
+
+export interface ParsedSuperSearchQuery {
+ query: string;
+ prefixTokens: SuperSearchPrefixToken[];
+ prefixedScopeIds: string[];
+}
+
+function buildScopeLookup(scopes: readonly SuperSearchScope[]) {
+ const lookup = new Map();
+ for (const scope of scopes) {
+ lookup.set(scope.id.toLowerCase(), scope.id);
+ for (const alias of scope.aliases ?? []) {
+ lookup.set(alias.toLowerCase(), scope.id);
+ }
+ }
+ return lookup;
+}
+
+/**
+ * Parses leading `scope:` tokens into active scopes and returns the remaining
+ * free-text query. Prefixes only count at the start so normal colon text in
+ * the search term is left alone.
+ */
+export function parseSuperSearchQuery(
+ rawQuery: string,
+ scopes: readonly SuperSearchScope[],
+): ParsedSuperSearchQuery {
+ if (scopes.length === 0) {
+ return {
+ query: rawQuery.trim(),
+ prefixTokens: [],
+ prefixedScopeIds: [],
+ };
+ }
+
+ const scopeLookup = buildScopeLookup(scopes);
+ const prefixTokens: SuperSearchPrefixToken[] = [];
+ let remainder = rawQuery.trimStart();
+
+ while (remainder.length > 0) {
+ const match = remainder.match(/^([^\s:]+):/);
+ if (!match) break;
+
+ const token = match[1];
+ const scopeId = scopeLookup.get(token.toLowerCase());
+ if (!scopeId) break;
+
+ prefixTokens.push({ scopeId, token: `${token}:` });
+ remainder = remainder.slice(match[0].length).trimStart();
+ }
+
+ return {
+ query: remainder.trim(),
+ prefixTokens,
+ prefixedScopeIds: [...new Set(prefixTokens.map((token) => token.scopeId))],
+ };
+}
+
+export function rebuildSuperSearchQuery(
+ parsed: ParsedSuperSearchQuery,
+ keepScopeIds: ReadonlySet,
+): string {
+ return [
+ ...parsed.prefixTokens
+ .filter((token) => keepScopeIds.has(token.scopeId))
+ .map((token) => token.token),
+ parsed.query,
+ ]
+ .filter(Boolean)
+ .join(" ");
+}
diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx
index 34c6ab5a51..d6cd86868b 100644
--- a/frontend/editor/src/core/components/tools/RightSidebar.tsx
+++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx
@@ -4,7 +4,6 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import ToolPanel from "@app/components/tools/ToolPanel";
-import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import { usePoliciesEnabled } from "@app/components/policies/usePoliciesEnabled";
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
@@ -17,7 +16,6 @@ import { ActionIcon } from "@app/ui/ActionIcon";
import { withViewTransition } from "@app/utils/viewTransition";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
import CloseIcon from "@mui/icons-material/Close";
-import SearchIcon from "@mui/icons-material/Search";
import { ToolId } from "@app/types/toolId";
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
@@ -43,7 +41,6 @@ export default function RightSidebar() {
const {
leftPanelView,
isPanelVisible,
- searchQuery,
filteredTools,
toolRegistry,
setSearchQuery,
@@ -79,7 +76,6 @@ export default function RightSidebar() {
};
const [allToolsView, setAllToolsView] = useState(false);
- const [headerSearchOpen, setHeaderSearchOpen] = useState(false);
const handleShowAllTools = () => {
withViewTransition(() => setAllToolsView(true));
@@ -97,7 +93,6 @@ export default function RightSidebar() {
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
- const showHeaderSearch = showCloseButton || headerSearchOpen;
const handleHeaderBack = () => {
if (inToolView) {
@@ -111,20 +106,6 @@ export default function RightSidebar() {
withViewTransition(() => handleToolSelect(id));
};
- // Typing in the header search while inside a tool exits the tool and lifts the
- // panel into the all-tools view so the user immediately sees search results.
- const handleHeaderSearchChange = (value: string) => {
- if (inToolView) {
- withViewTransition(() => {
- handleBackToTools();
- setAllToolsView(true);
- setSearchQuery(value);
- });
- return;
- }
- setSearchQuery(value);
- };
-
const activeTool: ToolRegistryEntry | null =
inToolView && selectedToolKey
? (toolRegistry[selectedToolKey as ToolId] ?? null)
@@ -235,6 +216,7 @@ export default function RightSidebar() {
flexShrink: 0,
display: "flex",
flexDirection: "column",
+ position: "relative",
}}
>
<>
@@ -257,41 +239,10 @@ export default function RightSidebar() {
/>
) : (
- {showHeaderSearch ? (
-
-
-
- ) : (
-
- {t("toolPanel.pdfTools", "PDF Tools")}
-
- )}
+
+ {t("toolPanel.pdfTools", "PDF Tools")}
+
- {!showCloseButton && (
-
{
- if (headerSearchOpen) handleHeaderSearchChange("");
- setHeaderSearchOpen((open) => !open);
- }}
- aria-label={t("toolPanel.searchTools", "Search tools")}
- className="tool-panel__expand-btn"
- >
- {headerSearchOpen ? (
-
- ) : (
-
- )}
-
- )}
{showCloseButton ? (
-
+
diff --git a/frontend/editor/src/core/data/processorEntitySearch.ts b/frontend/editor/src/core/data/processorEntitySearch.ts
new file mode 100644
index 0000000000..5d7eaa9364
--- /dev/null
+++ b/frontend/editor/src/core/data/processorEntitySearch.ts
@@ -0,0 +1,21 @@
+import type { TFunction } from "i18next";
+import type { SuperSearchGroup } from "@app/types/superSearch";
+
+const NO_GROUPS: SuperSearchGroup[] = [];
+
+/**
+ * Processor entity results (users, policies, pipelines, sources) for the
+ * editor's super search. Core and desktop builds ship no portal, so this stub
+ * returns nothing; the proprietary build shadows it with an implementation
+ * that lazily loads the portal's entity-search module.
+ */
+export function useProcessorEntityGroups(
+ _trimmed: string,
+ _enabled: boolean,
+ _t: TFunction,
+ _navigate: (path: string) => void,
+ _scopeEnabled?: (scopeId: string) => boolean,
+ _isAdmin?: boolean,
+): SuperSearchGroup[] {
+ return NO_GROUPS;
+}
diff --git a/frontend/editor/src/core/data/processorSearchIndex.ts b/frontend/editor/src/core/data/processorSearchIndex.ts
new file mode 100644
index 0000000000..ef308cbee2
--- /dev/null
+++ b/frontend/editor/src/core/data/processorSearchIndex.ts
@@ -0,0 +1,30 @@
+/**
+ * Search index for the admin portal ("Processor") destinations offered by the
+ * global super search. Core and desktop builds ship no portal, so the index is
+ * empty and the Processor group never renders; the proprietary build (which
+ * mounts the portal as a route-set) shadows this with the real view list.
+ */
+export interface ProcessorSearchEntry {
+ /** Portal view id — stable key for the result row. */
+ id: string;
+ /** i18n key for the view's display name (shared with the portal sidebar). */
+ labelKey: string;
+ labelFallback: string;
+ /** In-app path to navigate to. Empty when externalUrl is set. */
+ path: string;
+ /** Opens in a new tab instead of navigating (e.g. hosted docs). */
+ externalUrl?: string;
+ /** Extra fuzzy-match terms beyond the label. */
+ keywords?: string[];
+}
+
+export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = [];
+
+/** No portal → no entity scopes; trivially accessible. Real logic lives in
+ * the proprietary shadow, keyed on the flavor's users capabilities. */
+export function isPortalEntityScopeAccessible(
+ _scopeId: string,
+ _isAdmin: boolean,
+): boolean {
+ return true;
+}
diff --git a/frontend/editor/src/core/data/settingsContentSearch.ts b/frontend/editor/src/core/data/settingsContentSearch.ts
new file mode 100644
index 0000000000..7cee4d6056
--- /dev/null
+++ b/frontend/editor/src/core/data/settingsContentSearch.ts
@@ -0,0 +1,174 @@
+import i18n from "i18next";
+import type { TFunction } from "i18next";
+
+/**
+ * Content-level settings search: matches a query against every translation
+ * string a settings section renders, so a term that appears anywhere on a
+ * settings page ("SMTP", "OCR", a field label…) finds that section without a
+ * curated keyword.
+ *
+ * Component-free (translation subtrees only) so the always-mounted super
+ * search can use it without pulling the lazy settings modal into the main
+ * bundle.
+ */
+
+/**
+ * Translation subtrees whose strings appear on each settings section, for the
+ * sections where the nav key doesn't map 1:1 onto a toml prefix. Keys missing
+ * here fall back to the inferred `settings.
` / `admin.settings.`
+ * prefix.
+ */
+const SECTION_TRANSLATION_PREFIXES: Partial> = {
+ general: ["settings.general"],
+ hotkeys: ["settings.hotkeys"],
+ account: ["account"],
+ people: ["settings.workspace"],
+ teams: ["settings.workspace", "settings.team"],
+ "api-keys": ["settings.developer"],
+ connectionMode: ["settings.connection"],
+ planBilling: ["settings.planBilling"],
+ adminGeneral: ["admin.settings.general"],
+ adminFeatures: ["admin.settings.features"],
+ adminEndpoints: ["admin.settings.endpoints"],
+ adminDatabase: ["admin.settings.database"],
+ adminAdvanced: ["admin.settings.advanced"],
+ adminFolderAccess: ["admin.settings.folderAccess"],
+ // The four AI tabs all render slices of the one admin.settings.ai subtree.
+ adminAiGeneral: ["admin.settings.ai"],
+ adminAiModels: ["admin.settings.ai"],
+ adminAiDocuments: ["admin.settings.ai"],
+ adminAiLimits: ["admin.settings.ai"],
+ adminSecurity: ["admin.settings.security"],
+ adminMcp: ["admin.settings.mcp"],
+ adminConnections: [
+ "admin.settings.connections",
+ "admin.settings.mail",
+ "admin.settings.security",
+ "admin.settings.telegram",
+ "admin.settings.premium",
+ "admin.settings.general",
+ "settings.securityAuth",
+ "settings.connection",
+ ],
+ adminPlan: [
+ "settings.planBilling",
+ "admin.settings.premium",
+ "settings.licensingAnalytics",
+ ],
+ adminAudit: ["settings.licensingAnalytics"],
+ adminUsage: ["settings.licensingAnalytics"],
+ adminLegal: ["admin.settings.legal"],
+ adminPrivacy: ["admin.settings.privacy"],
+};
+
+export const getTranslationPrefixesForNavKey = (key: string): string[] => {
+ const explicitPrefixes = SECTION_TRANSLATION_PREFIXES[key] ?? [];
+
+ const inferredPrefixes: string[] = [];
+
+ if (key.startsWith("admin")) {
+ const adminSuffix = key.replace(/^admin/, "");
+ const normalizedAdminSuffix =
+ adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
+ inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
+ } else {
+ inferredPrefixes.push(`settings.${key}`);
+ }
+
+ return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
+};
+
+export const flattenTranslationStrings = (value: unknown): string[] => {
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ return trimmed ? [trimmed] : [];
+ }
+
+ if (Array.isArray(value)) {
+ return value.flatMap(flattenTranslationStrings);
+ }
+
+ if (value && typeof value === "object") {
+ return Object.values(value as Record).flatMap(
+ flattenTranslationStrings,
+ );
+ }
+
+ return [];
+};
+
+/** Trims a matched string to a short snippet centred on the query hit. */
+export const buildMatchSnippet = (text: string, query: string): string => {
+ const normalizedText = text.toLocaleLowerCase();
+ const normalizedQuery = query.toLocaleLowerCase();
+ const matchIndex = normalizedText.indexOf(normalizedQuery);
+
+ if (matchIndex === -1) {
+ return text;
+ }
+
+ // Lowercasing can change string length in some locales (Turkish İ, ß), so
+ // indices computed on the copy only align with the original when the
+ // lengths match; otherwise snippet the copy itself.
+ const source = normalizedText.length === text.length ? text : normalizedText;
+
+ const maxLength = 84;
+ const contextPadding = 28;
+ const start = Math.max(0, matchIndex - contextPadding);
+ const end = Math.min(
+ source.length,
+ matchIndex + query.length + contextPadding,
+ );
+ const snippet = source.slice(start, end);
+
+ if (snippet.length <= maxLength) {
+ return `${start > 0 ? "…" : ""}${snippet}${end < source.length ? "…" : ""}`;
+ }
+
+ return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < source.length ? "…" : ""}`;
+};
+
+// Flattening every subtree on each keystroke would be wasteful; sections'
+// content is static per language, so cache it and drop the cache on switch.
+const contentCache = new Map();
+let contentCacheLanguage: string | undefined;
+
+// Locale files load over HTTP after boot, so content computed before the
+// bundle resolves is empty — without this, an early query would cache empty
+// content for the whole session. Cleared whenever a resource bundle lands.
+i18n.on("loaded", () => contentCache.clear());
+
+export function getSettingsSectionContent(key: string, t: TFunction): string[] {
+ if (contentCacheLanguage !== i18n.language) {
+ contentCache.clear();
+ contentCacheLanguage = i18n.language;
+ }
+ const cached = contentCache.get(key);
+ if (cached) return cached;
+
+ const content = getTranslationPrefixesForNavKey(key).flatMap((prefix) =>
+ flattenTranslationStrings(
+ t(prefix, { returnObjects: true, defaultValue: {} }),
+ ),
+ );
+ contentCache.set(key, content);
+ return content;
+}
+
+/**
+ * First content string of the section containing the query
+ * (case-insensitive), or null. Substring only — fuzzy matching across whole
+ * paragraphs of copy produces junk hits.
+ */
+export function findSettingsContentMatch(
+ key: string,
+ query: string,
+ t: TFunction,
+): string | null {
+ const normalizedQuery = query.toLocaleLowerCase();
+ return (
+ getSettingsSectionContent(key, t).find((text) =>
+ text.toLocaleLowerCase().includes(normalizedQuery),
+ ) ?? null
+ );
+}
diff --git a/frontend/editor/src/core/data/settingsSearchIndex.ts b/frontend/editor/src/core/data/settingsSearchIndex.ts
new file mode 100644
index 0000000000..c130546a8a
--- /dev/null
+++ b/frontend/editor/src/core/data/settingsSearchIndex.ts
@@ -0,0 +1,105 @@
+import { NavKey } from "@app/components/shared/config/types";
+
+/**
+ * A single, searchable setting *row* inside the settings modal.
+ *
+ * Section-level content matching (settingsContentSearch) only navigates to a
+ * whole section; this index lets the global super search deep-link to an
+ * individual control: navigating to `/settings/{section}?focus={anchor}`,
+ * where `anchor` is the DOM `id` placed on that control's row (see
+ * AppConfigModal's focus-scroll effect and the `id=` attributes added to the
+ * matching section components).
+ */
+export interface SettingsSearchEntry {
+ /** Settings section this row lives in (nav key, e.g. "general"). */
+ section: NavKey;
+ /** DOM id on the control's row; used as the `?focus=` anchor. */
+ anchor: string;
+ /** i18n key for the display label. */
+ labelKey: string;
+ /** English fallback / default for the label. */
+ labelFallback: string;
+ /** Extra English terms to match against (synonyms, related words). */
+ keywords?: string[];
+}
+
+/**
+ * Curated row-level entries for the high-value, user-facing settings sections.
+ * Section-level results (every other tab) come from the nav sections directly,
+ * so this list only needs the rows worth jumping straight to.
+ */
+export const SETTINGS_SEARCH_INDEX: SettingsSearchEntry[] = [
+ // --- General > Appearance ---
+ {
+ section: "general",
+ anchor: "setting-theme",
+ labelKey: "settings.general.theme",
+ labelFallback: "Theme",
+ keywords: ["dark", "light", "mode", "appearance", "colour", "color"],
+ },
+ {
+ section: "general",
+ anchor: "setting-language",
+ labelKey: "settings.general.language",
+ labelFallback: "Language",
+ keywords: ["locale", "translation", "i18n"],
+ },
+ // --- General > Behaviour ---
+ {
+ section: "general",
+ anchor: "setting-tool-picker-mode",
+ labelKey: "settings.general.defaultToolPickerMode",
+ labelFallback: "Default tool picker mode",
+ keywords: ["sidebar", "fullscreen", "tools", "panel"],
+ },
+ {
+ section: "general",
+ anchor: "setting-startup-view",
+ labelKey: "settings.general.defaultStartupView",
+ labelFallback: "Default view on launch",
+ keywords: ["startup", "launch", "home", "reader", "automate"],
+ },
+ {
+ section: "general",
+ anchor: "setting-reader-zoom",
+ labelKey: "settings.general.defaultViewerZoom",
+ labelFallback: "Default reader zoom",
+ keywords: ["zoom", "viewer", "fit width", "fit page", "magnification"],
+ },
+ {
+ section: "general",
+ anchor: "setting-hide-unavailable-tools",
+ labelKey: "settings.general.hideUnavailableTools",
+ labelFallback: "Hide unavailable tools",
+ keywords: ["disabled", "greyed", "tools"],
+ },
+ {
+ section: "general",
+ anchor: "setting-hide-unavailable-conversions",
+ labelKey: "settings.general.hideUnavailableConversions",
+ labelFallback: "Hide unavailable conversions",
+ keywords: ["disabled", "convert", "conversions"],
+ },
+ {
+ section: "general",
+ anchor: "setting-auto-unzip",
+ labelKey: "settings.general.autoUnzip",
+ labelFallback: "Auto-unzip API responses",
+ keywords: ["zip", "extract", "unzip", "archive"],
+ },
+ {
+ section: "general",
+ anchor: "setting-auto-unzip-file-limit",
+ labelKey: "settings.general.autoUnzipFileLimit",
+ labelFallback: "Auto-unzip file limit",
+ keywords: ["zip", "limit", "files", "extract"],
+ },
+ // --- Keyboard Shortcuts ---
+ {
+ section: "hotkeys",
+ anchor: "setting-hotkeys-search",
+ labelKey: "settings.hotkeys.title",
+ labelFallback: "Keyboard Shortcuts",
+ keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
+ },
+];
diff --git a/frontend/editor/src/core/data/settingsSectionRegistry.ts b/frontend/editor/src/core/data/settingsSectionRegistry.ts
new file mode 100644
index 0000000000..e2222d4daf
--- /dev/null
+++ b/frontend/editor/src/core/data/settingsSectionRegistry.ts
@@ -0,0 +1,88 @@
+import { NavKey } from "@app/components/shared/config/types";
+
+/**
+ * A whole settings section (nav tab), described as pure data so the global
+ * super search can offer section-level results.
+ *
+ * This is the single source of truth for *which settings sections are
+ * searchable* in a given build. It is intentionally **component-free**: the
+ * always-mounted top bar imports it (via `@app/data/settingsSectionRegistry`)
+ * to feed the super search, and pulling the heavy settings component tree in
+ * here would defeat the lazy-loaded settings modal (AppConfigModalLazy).
+ *
+ * Layering mirrors the nav builders (`configNavSections`): core lists the
+ * always-present sections; higher layers shadow this module to add their own
+ * (proprietary admin sections, saas cloud sections, …). A section only belongs
+ * in a layer's registry if that layer's settings modal can actually render it —
+ * otherwise search would deep-link to a tab that doesn't exist.
+ *
+ * Per-user visibility (admin / login) is expressed by the flags below and
+ * applied by the super search at query time, mirroring the modal's own gating.
+ */
+export interface SettingsSectionEntry {
+ /** Nav key; used for the `/settings/{key}` deep link. */
+ key: NavKey;
+ /** i18n key for the display label. */
+ labelKey: string;
+ /** English fallback / default for the label. */
+ labelFallback: string;
+ /**
+ * The modal nav group this section sits under ("Preferences",
+ * "Configuration", …) — shown as result context ("Group · match").
+ * Same keys the nav builder uses.
+ */
+ groupLabelKey?: string;
+ groupLabelFallback?: string;
+ /** Extra English terms to match against (synonyms, related words). */
+ keywords?: string[];
+ /** Surface only when login mode is on (e.g. account, API keys). */
+ requiresLogin?: boolean;
+ /**
+ * Admin-area section: the self-hosted modal surfaces it when the user is an
+ * admin OR login mode is off (single-user self-host). Mirrors the proprietary
+ * nav builder's `isAdmin || !loginEnabled` gate.
+ */
+ adminArea?: boolean;
+ /**
+ * Section only mounts for a signed-in (non-anonymous) account — mirrors the
+ * SaaS nav builder's `!isAnonymous` gate. Distinct from `requiresLogin`,
+ * which keys off the local backend's login mode rather than auth state.
+ */
+ requiresAccount?: boolean;
+}
+
+/** Core (OSS) sections — always present in every build. */
+export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
+ {
+ key: "general",
+ labelKey: "settings.general.title",
+ labelFallback: "General",
+ keywords: ["theme", "language", "appearance", "preferences", "startup"],
+ groupLabelKey: "settings.preferences.title",
+ groupLabelFallback: "Preferences",
+ },
+ {
+ key: "hotkeys",
+ labelKey: "settings.hotkeys.title",
+ labelFallback: "Keyboard Shortcuts",
+ keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
+ groupLabelKey: "settings.preferences.title",
+ groupLabelFallback: "Preferences",
+ },
+ {
+ key: "help",
+ labelKey: "settings.help.label",
+ labelFallback: "Tours",
+ keywords: ["help", "tour", "guide", "support", "docs"],
+ groupLabelKey: "settings.help.title",
+ groupLabelFallback: "Help",
+ },
+ {
+ key: "legal",
+ labelKey: "settings.legal.label",
+ labelFallback: "Legal",
+ keywords: ["legal", "terms", "privacy", "licenses"],
+ groupLabelKey: "settings.legal.title",
+ groupLabelFallback: "Legal",
+ },
+];
diff --git a/frontend/editor/src/core/data/toolsTaxonomy.ts b/frontend/editor/src/core/data/toolsTaxonomy.ts
index fc9b37565c..f9c2dde02f 100644
--- a/frontend/editor/src/core/data/toolsTaxonomy.ts
+++ b/frontend/editor/src/core/data/toolsTaxonomy.ts
@@ -216,6 +216,17 @@ export const isValidToolId = (
return toolId in registry;
};
+/**
+ * A "coming soon" placeholder: listed in the catalogue but not openable — no
+ * UI component and no external link. "read" and "multiTool" are exempt as
+ * workbench-only tools that render without a component.
+ */
+export const isComingSoonTool = (
+ toolId: string,
+ tool: ToolRegistryEntry,
+): boolean =>
+ !tool.component && !tool.link && toolId !== "read" && toolId !== "multiTool";
+
/**
* Check if a tool supports automation (defaults to true)
*/
diff --git a/frontend/editor/src/core/hooks/useScopedFetchCache.test.ts b/frontend/editor/src/core/hooks/useScopedFetchCache.test.ts
new file mode 100644
index 0000000000..2965fc2898
--- /dev/null
+++ b/frontend/editor/src/core/hooks/useScopedFetchCache.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { useScopedFetchCache } from "@app/hooks/useScopedFetchCache";
+
+const TTL = 30_000;
+
+describe("useScopedFetchCache", () => {
+ it("exposes fetched values and clears loading", async () => {
+ const fetcher = vi.fn(async (key: string) => `value-${key}`);
+ const { result, rerender } = renderHook(
+ ({ keys }: { keys: readonly string[] }) =>
+ useScopedFetchCache(keys, fetcher, TTL),
+ { initialProps: { keys: ["a", "b"] as readonly string[] } },
+ );
+
+ expect(result.current.loading).toBe(true);
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.values).toEqual({ a: "value-a", b: "value-b" });
+ expect(fetcher).toHaveBeenCalledTimes(2);
+
+ // Fresh keys within the TTL are served from cache, not refetched.
+ rerender({ keys: ["a", "b"] });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ });
+
+ it("stamps failed keys so they are not retried within the TTL", async () => {
+ const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
+ const fetcher = vi.fn(async () => {
+ throw new Error("source unavailable");
+ });
+ const { result, rerender } = renderHook(
+ ({ keys }: { keys: readonly string[] }) =>
+ useScopedFetchCache(keys, fetcher, TTL),
+ { initialProps: { keys: ["a"] as readonly string[] } },
+ );
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.values).toEqual({});
+ expect(fetcher).toHaveBeenCalledTimes(1);
+
+ // Re-requesting the failed key (a keystroke re-render) must not refire.
+ rerender({ keys: ["a"] });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ debug.mockRestore();
+ });
+
+ it("keeps successes when a sibling key fails", async () => {
+ const fetcher = vi.fn(async (key: string) => {
+ if (key === "bad") throw new Error("nope");
+ return `value-${key}`;
+ });
+ const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
+ const { result } = renderHook(() =>
+ useScopedFetchCache(["good", "bad"], fetcher, TTL),
+ );
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.values).toEqual({ good: "value-good" });
+ debug.mockRestore();
+ });
+});
diff --git a/frontend/editor/src/core/hooks/useScopedFetchCache.ts b/frontend/editor/src/core/hooks/useScopedFetchCache.ts
new file mode 100644
index 0000000000..2b965f556b
--- /dev/null
+++ b/frontend/editor/src/core/hooks/useScopedFetchCache.ts
@@ -0,0 +1,131 @@
+import { useEffect, useRef, useState } from "react";
+
+interface FetchSuccess {
+ key: K;
+ status: "fulfilled";
+ value: V;
+}
+interface FetchFailure {
+ key: K;
+ status: "rejected";
+ reason: unknown;
+}
+type FetchOutcome = FetchSuccess | FetchFailure;
+
+export interface ScopedFetchCache {
+ /** Latest successfully fetched value per key; absent until first success. */
+ values: Partial>;
+ /** True while any requested key is being (re)fetched. */
+ loading: boolean;
+}
+
+/**
+ * A keyed async cache for search sources: each requested key is fetched at
+ * most once per TTL window, concurrent requests for the same key share one
+ * in-flight promise, and results from a superseded request generation are
+ * dropped (though their in-flight promises are still awaited by the next
+ * generation rather than re-fired).
+ *
+ * Failed keys are stamped like successes — a deployment without that endpoint
+ * answers the same way on every keystroke, so hammering it per keypress buys
+ * nothing. The failure is logged at debug level and retried after the TTL.
+ *
+ * `fetchKey` identity is the cache's world-view: when it changes (e.g. a tier
+ * change producing a different fetcher), every key is considered stale.
+ */
+export function useScopedFetchCache(
+ requestedKeys: readonly K[],
+ fetchKey: (key: K) => Promise,
+ ttlMs: number,
+): ScopedFetchCache {
+ const [values, setValues] = useState>>({});
+ const [loading, setLoading] = useState(false);
+ const fetchedAtRef = useRef(new Map());
+ const inFlightRef = useRef(new Map>>());
+ const requestIdRef = useRef(0);
+ const fetcherRef = useRef(fetchKey);
+
+ useEffect(() => {
+ if (fetcherRef.current !== fetchKey) {
+ fetcherRef.current = fetchKey;
+ fetchedAtRef.current.clear();
+ inFlightRef.current.clear();
+ // Values from the old world-view must not surface under the new one
+ // (e.g. a tier change altering what a payload contains).
+ setValues({});
+ }
+
+ if (requestedKeys.length === 0) {
+ requestIdRef.current += 1;
+ setLoading(false);
+ return;
+ }
+
+ const now = Date.now();
+ const staleKeys = requestedKeys.filter(
+ (key) => now - (fetchedAtRef.current.get(key) ?? 0) >= ttlMs,
+ );
+ if (staleKeys.length === 0) {
+ setLoading(false);
+ return;
+ }
+
+ const requestId = requestIdRef.current + 1;
+ requestIdRef.current = requestId;
+ setLoading(true);
+
+ void Promise.all(
+ staleKeys.map((key) => {
+ const existing = inFlightRef.current.get(key);
+ if (existing) return existing;
+
+ const request = fetchKey(key)
+ .then((value) => ({ key, status: "fulfilled" as const, value }))
+ .catch((reason) => ({ key, status: "rejected" as const, reason }))
+ .finally(() => {
+ if (inFlightRef.current.get(key) === request) {
+ inFlightRef.current.delete(key);
+ }
+ });
+ inFlightRef.current.set(key, request);
+ return request;
+ }),
+ ).then((results) => {
+ if (requestIdRef.current !== requestId) return;
+
+ const fetchedAt = Date.now();
+ for (const result of results) {
+ // Stamp failures too — see the hook doc.
+ fetchedAtRef.current.set(result.key, fetchedAt);
+ if (result.status === "rejected") {
+ console.debug(
+ "[useScopedFetchCache] source unavailable:",
+ result.key,
+ result.reason,
+ );
+ }
+ }
+
+ const fulfilled = results.filter(
+ (result): result is FetchSuccess => result.status === "fulfilled",
+ );
+ if (fulfilled.length > 0) {
+ setValues((current) => {
+ const next = { ...current };
+ for (const result of fulfilled) next[result.key] = result.value;
+ return next;
+ });
+ }
+
+ setLoading(false);
+ });
+
+ return () => {
+ if (requestIdRef.current === requestId) {
+ requestIdRef.current += 1;
+ }
+ };
+ }, [requestedKeys, fetchKey, ttlMs]);
+
+ return { values, loading };
+}
diff --git a/frontend/editor/src/core/hooks/useSuperSearch.test.ts b/frontend/editor/src/core/hooks/useSuperSearch.test.ts
new file mode 100644
index 0000000000..0f1f573e8b
--- /dev/null
+++ b/frontend/editor/src/core/hooks/useSuperSearch.test.ts
@@ -0,0 +1,286 @@
+import React from "react";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("react-router-dom", () => ({
+ useNavigate: vi.fn(),
+}));
+
+vi.mock("@app/contexts/ToolWorkflowContext", () => ({
+ useToolWorkflow: vi.fn(),
+}));
+
+vi.mock("@app/contexts/NavigationContext", () => ({
+ useNavigationActions: vi.fn(),
+}));
+
+vi.mock("@app/contexts/ViewerContext", () => ({
+ ViewerContext: React.createContext(null),
+}));
+
+vi.mock("@app/contexts/AppConfigContext", () => ({
+ useAppConfig: vi.fn(),
+}));
+
+vi.mock("@app/contexts/file/fileHooks", () => ({
+ useFileActions: vi.fn(),
+}));
+
+vi.mock("@app/auth/UseSession", () => ({
+ useAuth: vi.fn(() => ({
+ portalAccess: false,
+ isAdmin: false,
+ role: null,
+ })),
+}));
+
+vi.mock("@app/services/fileStorage", () => ({
+ fileStorage: {
+ getLeafStirlingFileStubs: vi.fn(),
+ },
+}));
+
+vi.mock("@app/data/settingsSearchIndex", () => ({
+ SETTINGS_SEARCH_INDEX: [
+ {
+ section: "email",
+ anchor: "smtp-host",
+ labelKey: "settings.email.smtpHost",
+ labelFallback: "SMTP host",
+ keywords: ["smtp"],
+ },
+ ],
+}));
+
+vi.mock("@app/data/settingsSectionRegistry", () => ({
+ SETTINGS_SECTION_REGISTRY: [
+ {
+ key: "general",
+ labelKey: "settings.general.title",
+ labelFallback: "General",
+ keywords: ["general"],
+ },
+ {
+ key: "email",
+ labelKey: "settings.email.title",
+ labelFallback: "Email",
+ keywords: ["mail"],
+ requiresLogin: true,
+ },
+ {
+ key: "admin",
+ labelKey: "settings.admin.title",
+ labelFallback: "Admin",
+ keywords: ["admin"],
+ adminArea: true,
+ },
+ {
+ key: "teams",
+ labelKey: "settings.teams.title",
+ labelFallback: "Team",
+ keywords: ["team"],
+ requiresAccount: true,
+ },
+ ],
+}));
+
+vi.mock("@app/data/settingsContentSearch", () => ({
+ findSettingsContentMatch: vi.fn((section: string, query: string) => {
+ if (query === "smtp" && (section === "general" || section === "email")) {
+ return { section, query };
+ }
+ return null;
+ }),
+ buildMatchSnippet: vi.fn(
+ (_match: unknown, query: string) => `Match: ${query}`,
+ ),
+}));
+
+vi.mock("@app/data/processorSearchIndex", () => ({
+ PROCESSOR_SEARCH_INDEX: [
+ {
+ id: "users",
+ labelKey: "superSearch.processor.users",
+ labelFallback: "Users",
+ path: "/users",
+ keywords: ["members"],
+ },
+ {
+ id: "docs",
+ labelKey: "superSearch.processor.docs",
+ labelFallback: "Docs",
+ path: "",
+ externalUrl: "https://example.com/docs",
+ keywords: ["manual"],
+ },
+ ],
+}));
+
+import type { TFunction } from "i18next";
+import {
+ assembleSuperSearchGroups,
+ rankProcessorResults,
+ rankSettingsResults,
+} from "@app/hooks/useSuperSearch";
+
+const t = ((key: string, fallback?: string) =>
+ fallback ?? key) as unknown as TFunction;
+
+describe("useSuperSearch helpers", () => {
+ it("drops empty groups and preserves the requested group order", () => {
+ const groups = assembleSuperSearchGroups(
+ {
+ tools: [
+ {
+ key: "tool:rotate",
+ group: "tools",
+ title: "Rotate",
+ score: 60,
+ onSelect: vi.fn(),
+ },
+ ],
+ processor: [
+ {
+ key: "processor:users",
+ group: "processor",
+ title: "Users",
+ score: 70,
+ onSelect: vi.fn(),
+ },
+ ],
+ },
+ t,
+ ["processor", "settings", "tools", "files"],
+ );
+
+ expect(groups.map((group) => group.id)).toEqual(["processor", "tools"]);
+ expect(groups.map((group) => group.label)).toEqual(["Processor", "Tools"]);
+ });
+
+ it("prefers row-level setting hits and skips duplicate content matches", () => {
+ const openSettings = vi.fn();
+
+ const results = rankSettingsResults(
+ "smtp",
+ t,
+ {
+ isAdmin: false,
+ loginEnabled: true,
+ },
+ openSettings,
+ );
+
+ expect(results.map((result) => result.key)).toEqual([
+ "setting:email:smtp-host",
+ "setting-content:general",
+ ]);
+
+ void results[0]?.onSelect();
+ expect(openSettings).toHaveBeenCalledWith("email", "smtp-host");
+ });
+
+ it("keeps gated settings hidden while app config is unresolved", () => {
+ const results = rankSettingsResults("admin", t, null, vi.fn());
+
+ expect(results).toEqual([]);
+ expect(rankSettingsResults("team", t, null, vi.fn())).toEqual([]);
+ });
+
+ it("respects showSettingsWhenNoLogin for the no-login admin preview", () => {
+ const noLoginGates = { isAdmin: false, loginEnabled: false };
+
+ // Default (flag unset / true): no-login mode keeps the admin preview.
+ const shown = rankSettingsResults("admin", t, noLoginGates, vi.fn());
+ expect(shown.map((result) => result.key)).toEqual([
+ "setting-section:admin",
+ ]);
+
+ // Flag off: the modal hides admin sections, so search must too.
+ const hidden = rankSettingsResults(
+ "admin",
+ t,
+ { ...noLoginGates, showSettingsWhenNoLogin: false },
+ vi.fn(),
+ );
+ expect(hidden).toEqual([]);
+
+ // Admins keep admin sections regardless of the flag.
+ const admin = rankSettingsResults(
+ "admin",
+ t,
+ { isAdmin: true, loginEnabled: true, showSettingsWhenNoLogin: false },
+ vi.fn(),
+ );
+ expect(admin.map((result) => result.key)).toEqual([
+ "setting-section:admin",
+ ]);
+ });
+
+ it("hides account-bound settings from anonymous sessions", () => {
+ const anonymous = rankSettingsResults(
+ "team",
+ t,
+ { isAdmin: false, loginEnabled: true, isAnonymous: true },
+ vi.fn(),
+ );
+ expect(anonymous).toEqual([]);
+
+ const signedIn = rankSettingsResults(
+ "team",
+ t,
+ { isAdmin: false, loginEnabled: true, isAnonymous: false },
+ vi.fn(),
+ );
+ expect(signedIn.map((result) => result.key)).toEqual([
+ "setting-section:teams",
+ ]);
+
+ // Hosts that omit the flag (portal, always signed-in) keep the section.
+ const flagless = rankSettingsResults(
+ "team",
+ t,
+ { isAdmin: false, loginEnabled: true },
+ vi.fn(),
+ );
+ expect(flagless.map((result) => result.key)).toEqual([
+ "setting-section:teams",
+ ]);
+ });
+
+ it("keeps the Processor group closed until gates resolve, then opens it for single-user mode", () => {
+ const selectEntry = vi.fn();
+
+ expect(rankProcessorResults("members", t, null, selectEntry)).toEqual([]);
+
+ const results = rankProcessorResults(
+ "members",
+ t,
+ {
+ isAdmin: false,
+ loginEnabled: false,
+ },
+ selectEntry,
+ );
+
+ expect(results.map((result) => result.key)).toEqual(["processor:users"]);
+
+ void results[0]?.onSelect();
+ expect(selectEntry).toHaveBeenCalledWith(
+ expect.objectContaining({ id: "users", path: "/users" }),
+ );
+ });
+
+ it("opens the Processor group for a user with explicit portal access", () => {
+ const results = rankProcessorResults(
+ "members",
+ t,
+ {
+ isAdmin: false,
+ loginEnabled: true,
+ portalAccessible: true,
+ },
+ vi.fn(),
+ );
+
+ expect(results.map((result) => result.key)).toEqual(["processor:users"]);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/useSuperSearch.ts b/frontend/editor/src/core/hooks/useSuperSearch.ts
new file mode 100644
index 0000000000..9f6a16f2f4
--- /dev/null
+++ b/frontend/editor/src/core/hooks/useSuperSearch.ts
@@ -0,0 +1,663 @@
+import {
+ createElement,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import type { TFunction } from "i18next";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate } from "react-router-dom";
+
+import { useAuth } from "@app/auth/UseSession";
+import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
+import { useNavigationActions } from "@app/contexts/NavigationContext";
+import { ViewerContext } from "@app/contexts/ViewerContext";
+import { useAppConfig } from "@app/contexts/AppConfigContext";
+import { useFileActions } from "@app/contexts/file/fileHooks";
+import { fileStorage } from "@app/services/fileStorage";
+import { FileDocIcon } from "@app/components/shared/FileDocIcon";
+import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
+import { detectFileExtension } from "@app/utils/fileUtils";
+import { openExternalUrl } from "@app/utils/safeNavigation";
+import { EDITOR_BASENAME } from "@app/routes/editorBasename";
+import {
+ rankByFuzzy,
+ idToWords,
+ FUZZY_MIN_SCORE,
+} from "@app/utils/fuzzySearch";
+import type { StirlingFileStub } from "@app/types/fileContext";
+import type { ToolId } from "@app/types/toolId";
+import { isComingSoonTool, type ToolRegistry } from "@app/data/toolsTaxonomy";
+import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex";
+import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry";
+import {
+ buildMatchSnippet,
+ findSettingsContentMatch,
+} from "@app/data/settingsContentSearch";
+import {
+ PROCESSOR_SEARCH_INDEX,
+ isPortalEntityScopeAccessible,
+ type ProcessorSearchEntry,
+} from "@app/data/processorSearchIndex";
+import { useProcessorEntityGroups } from "@app/data/processorEntitySearch";
+import {
+ PORTAL_ENTITY_SCOPE_DEFS,
+ PORTAL_DOCS_SCOPE_ID,
+ type SuperSearchGates,
+ type SuperSearchGroup,
+ type SuperSearchGroupId,
+ type SuperSearchQueryOptions,
+ type SuperSearchResult,
+ type SuperSearchScope,
+ type UseSuperSearchResult,
+} from "@app/types/superSearch";
+
+// Re-exported so existing consumers keep one import site; the definitions
+// live in the types leaf (see that module for why).
+export type {
+ SuperSearchGates,
+ SuperSearchGroup,
+ SuperSearchGroupId,
+ SuperSearchQueryOptions,
+ SuperSearchResult,
+ SuperSearchScope,
+ UseSuperSearchResult,
+};
+
+/**
+ * How many results a ranker computes per group. This is the ceiling the
+ * dropdown can reveal via "show more" — the component shows a small initial
+ * slice per group and expands to the rest on demand.
+ */
+export const GROUP_RESULT_CEILING = 24;
+/** Group display order in the dropdown. */
+const GROUP_ORDER: SuperSearchGroupId[] = [
+ "files",
+ "tools",
+ "settings",
+ "processor",
+];
+
+/**
+ * Whether the current user can enter the Processor at all: explicit portal
+ * access, admin, or single-user mode with login disabled. Null gates (config
+ * still loading) stay closed.
+ */
+export function isProcessorGateOpen(gates: SuperSearchGates | null): boolean {
+ return (
+ !!gates &&
+ (gates.portalAccessible === true || gates.isAdmin || !gates.loginEnabled)
+ );
+}
+
+/** The editor's visibility gates, from app config + the session's flags. */
+export function useSuperSearchGates(): SuperSearchGates | null {
+ const authState = useAuth();
+ const { config } = useAppConfig();
+ return useMemo(
+ () =>
+ config
+ ? {
+ isAdmin: authState.isAdmin ?? config.isAdmin ?? false,
+ loginEnabled: config.enableLogin ?? false,
+ portalAccessible: authState.portalAccess ?? false,
+ isAnonymous: authState.isAnonymous,
+ showSettingsWhenNoLogin: config.showSettingsWhenNoLogin ?? true,
+ }
+ : null,
+ [authState.isAdmin, authState.isAnonymous, authState.portalAccess, config],
+ );
+}
+
+/**
+ * The editor bar's filter chips — one per source lane, granular over the
+ * Processor's contents (pages plus each entity type). Processor lanes only
+ * show when this build ships the portal and the user can enter it.
+ */
+export function useEditorSearchScopes(): SuperSearchScope[] {
+ const { t } = useTranslation();
+ const gates = useSuperSearchGates();
+ const processorAvailable =
+ PROCESSOR_SEARCH_INDEX.length > 0 && isProcessorGateOpen(gates);
+
+ return useMemo(() => {
+ const visibleViewIds = new Set(
+ PROCESSOR_SEARCH_INDEX.map((entry) => entry.id),
+ );
+ return [
+ {
+ id: "files",
+ label: t("superSearch.group.files", "Files"),
+ aliases: ["file", "files"],
+ },
+ {
+ id: "tools",
+ label: t("superSearch.group.tools", "Tools"),
+ aliases: ["tool", "tools"],
+ },
+ {
+ id: "settings",
+ label: t("superSearch.group.settings", "Settings"),
+ aliases: ["setting", "settings"],
+ },
+ ...(processorAvailable
+ ? [
+ {
+ id: "processor",
+ label: t("superSearch.group.pages", "Pages"),
+ aliases: ["page", "pages", "processor", "portal"],
+ },
+ ...PORTAL_ENTITY_SCOPE_DEFS.filter(
+ (def) =>
+ visibleViewIds.has(def.viewId) &&
+ isPortalEntityScopeAccessible(def.id, gates?.isAdmin ?? false),
+ ).map((def) => ({
+ id: def.id,
+ label: t(def.labelKey, def.labelFallback),
+ aliases: [...def.aliases],
+ })),
+ ...(visibleViewIds.has("docs")
+ ? [
+ {
+ id: PORTAL_DOCS_SCOPE_ID,
+ label: t("superSearch.group.docs", "Docs"),
+ aliases: ["doc", "docs", "documentation"],
+ },
+ ]
+ : []),
+ ]
+ : []),
+ ];
+ }, [t, processorAvailable, gates?.isAdmin]);
+}
+
+/**
+ * Shared scope handling for hosts that accept SuperSearchQueryOptions:
+ * which source lanes are enabled for the current chip/prefix selection.
+ */
+export function useSearchScopeFilter(options?: SuperSearchQueryOptions): {
+ scopeEnabled: (scopeId: string) => boolean;
+} {
+ const scopedIds = useMemo(
+ () => new Set(options?.scopeIds ?? []),
+ [options?.scopeIds],
+ );
+ const hasScopedSearch = scopedIds.size > 0;
+ const scopeEnabled = useCallback(
+ (scopeId: string) => !hasScopedSearch || scopedIds.has(scopeId),
+ [hasScopedSearch, scopedIds],
+ );
+ return { scopeEnabled };
+}
+
+// ---------------------------------------------------------------------------
+// Shared sources. Every host bar (editor workbench, portal shell) builds its
+// results from these, so a query ranks identically everywhere — only the
+// select actions differ (in-app contexts vs cross-app navigation).
+// ---------------------------------------------------------------------------
+
+/** Loads the My Files stubs whenever the search surface is open. */
+export function useMyFilesStubs(active: boolean): {
+ stubs: StirlingFileStub[];
+ loadingFiles: boolean;
+} {
+ const [stubs, setStubs] = useState([]);
+ const [loadingFiles, setLoadingFiles] = useState(false);
+ const loadedOnceRef = useRef(false);
+
+ useEffect(() => {
+ if (!active) return;
+ // Refresh whenever the surface opens so newly added files appear.
+ let cancelled = false;
+ if (!loadedOnceRef.current) setLoadingFiles(true);
+ fileStorage
+ .getLeafStirlingFileStubs()
+ .then((all) => {
+ if (cancelled) return;
+ setStubs(all);
+ loadedOnceRef.current = true;
+ })
+ .catch((err) => {
+ console.error("[SuperSearch] Failed to load file stubs:", err);
+ })
+ .finally(() => {
+ if (!cancelled) setLoadingFiles(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [active]);
+
+ return { stubs, loadingFiles };
+}
+
+export function rankFileResults(
+ stubs: StirlingFileStub[],
+ trimmed: string,
+ openFile: (stub: StirlingFileStub) => void | Promise,
+ limit = GROUP_RESULT_CEILING,
+): SuperSearchResult[] {
+ if (!trimmed) return [];
+ return rankByFuzzy(stubs, trimmed, [(s) => s.name])
+ .slice(0, limit)
+ .map(({ item, score }) => ({
+ key: `file:${item.id}`,
+ group: "files",
+ title: item.name,
+ // The file-type doc icon (PDF/image/doc/…) the sidebar and grid use,
+ // rather than a flat generic file glyph. Sized by height so the portrait
+ // doc shape sits level with the square tool/settings icons instead of
+ // overflowing the row.
+ icon: createElement(FileDocIcon, {
+ variant: getFileDocVariant(
+ detectFileExtension(item.name.toLowerCase()),
+ (item.type ?? "").toLowerCase(),
+ ),
+ style: { height: "1.15rem", width: "auto" },
+ }),
+ score,
+ onSelect: () => openFile(item),
+ }));
+}
+
+export function rankToolResults(
+ registry: Partial,
+ trimmed: string,
+ openTool: (id: ToolId) => void,
+ limit = GROUP_RESULT_CEILING,
+): SuperSearchResult[] {
+ if (!trimmed) return [];
+ // Coming-soon placeholders are listed in the catalogue but can't open —
+ // selecting one would land on the "tool not found" panel.
+ const entries = (
+ Object.entries(registry) as [ToolId, ToolRegistry[ToolId] | undefined][]
+ ).filter(([id, tool]) => tool && !isComingSoonTool(id, tool));
+ return rankByFuzzy(entries, trimmed, [
+ ([id]) => idToWords(id),
+ ([, v]) => v?.name ?? "",
+ ([, v]) => v?.description ?? "",
+ ([, v]) => v?.synonyms?.join(" ") ?? "",
+ ])
+ .slice(0, limit)
+ .map(({ item: [id, tool], score }) => ({
+ key: `tool:${id}`,
+ group: "tools",
+ title: tool?.name ?? id,
+ subtitle: tool?.description,
+ icon: tool?.icon,
+ score,
+ onSelect: () => openTool(id),
+ }));
+}
+
+export function rankSettingsResults(
+ trimmed: string,
+ t: TFunction,
+ gates: SuperSearchGates | null,
+ openSettings: (section: string, anchor?: string) => void,
+ limit = GROUP_RESULT_CEILING,
+ /** Sections the host's settings modal refuses to show (e.g. the portal's
+ * hiddenSectionKeys) — offering them would deep-link into a blank modal. */
+ excludeSections?: readonly string[],
+): SuperSearchResult[] {
+ if (!trimmed) return [];
+
+ // Sections gated like the modal nav. The registry resolves per build
+ // (core / proprietary / saas / desktop), so this only ever sees sections
+ // the current build's settings modal can actually show.
+ const visibleSections = SETTINGS_SECTION_REGISTRY.filter((s) => {
+ if (excludeSections?.includes(s.key)) return false;
+ // Null gates (config still loading): hide every gated section.
+ // requiresLogin keys off the deployment's login *mode*, mirroring the nav
+ // builder: with login on the editor is login-walled (an unauthenticated
+ // visitor never reaches this code), and with login off those sections
+ // (account, API keys) have no meaning. Actual per-session auth state only
+ // matters on SaaS, where requiresAccount/isAnonymous carries it.
+ if (s.requiresLogin && !(gates?.loginEnabled ?? false)) return false;
+ // Admin-area sections mirror the builder's gate: admins always; no-login
+ // mode only while system.showSettingsWhenNoLogin keeps the admin preview.
+ const adminGateOpen =
+ !!gates &&
+ (gates.isAdmin ||
+ (!gates.loginEnabled && (gates.showSettingsWhenNoLogin ?? true)));
+ if (s.adminArea && !adminGateOpen) return false;
+ // Account-bound sections mirror the SaaS builder's `!isAnonymous` gate.
+ if (s.requiresAccount && (gates ? (gates.isAnonymous ?? false) : true))
+ return false;
+ return true;
+ });
+ // Row context: the display label of the section the row lives in.
+ const sectionLabelFor = new Map(
+ visibleSections.map((s) => [s.key, t(s.labelKey, s.labelFallback)]),
+ );
+
+ // Row-level entries (deep-link with ?focus=) take priority. Rows for
+ // sections this build/user can't open are dropped with them.
+ const rowMatches = rankByFuzzy(
+ SETTINGS_SEARCH_INDEX.filter((e) => sectionLabelFor.has(e.section)),
+ trimmed,
+ [
+ (e) => t(e.labelKey, e.labelFallback),
+ (e) => e.labelFallback,
+ (e) => e.keywords?.join(" ") ?? "",
+ ],
+ );
+ const rows = rowMatches.map(({ item, score }) => ({
+ key: `setting:${item.section}:${item.anchor}`,
+ group: "settings",
+ title: t(item.labelKey, item.labelFallback),
+ subtitle: sectionLabelFor.get(item.section),
+ iconName: "settings-rounded",
+ score: score + 1, // nudge rows above bare section matches
+ onSelect: () => openSettings(item.section, item.anchor),
+ }));
+ // The nav group a section lives under, shown as result context (joined to a
+ // content-match snippet with " · ").
+ const groupTitle = (s: (typeof SETTINGS_SECTION_REGISTRY)[number]) =>
+ s.groupLabelKey
+ ? t(s.groupLabelKey, s.groupLabelFallback ?? "")
+ : undefined;
+
+ const sectionMatches = rankByFuzzy(visibleSections, trimmed, [
+ (s) => t(s.labelKey, s.labelFallback),
+ (s) => s.labelFallback,
+ (s) => s.keywords?.join(" ") ?? "",
+ ]);
+ const sections = sectionMatches.map(({ item, score }) => ({
+ key: `setting-section:${item.key}`,
+ group: "settings",
+ title: t(item.labelKey, item.labelFallback),
+ subtitle: groupTitle(item),
+ iconName: "settings-rounded",
+ score,
+ onSelect: () => openSettings(item.key),
+ }));
+
+ // Content matches: sections whose rendered copy contains the query, so terms
+ // with no curated keyword ("SMTP", a field label) still find their section.
+ // Ranked below every label/keyword match; 3+ chars so a single letter doesn't
+ // match half the modal. Sections already surfaced by a label match — their
+ // own or a row's — are skipped so the same hit isn't listed twice.
+ const labelMatchedKeys = new Set([
+ ...sectionMatches.map(({ item }) => item.key),
+ ...rowMatches.map(({ item }) => item.section),
+ ]);
+ const contentMatches =
+ trimmed.length < 3
+ ? []
+ : visibleSections
+ .filter((s) => !labelMatchedKeys.has(s.key))
+ .flatMap((s) => {
+ const match = findSettingsContentMatch(s.key, trimmed, t);
+ if (!match) return [];
+ const snippet = buildMatchSnippet(match, trimmed);
+ const group = groupTitle(s);
+ return [
+ {
+ key: `setting-content:${s.key}`,
+ group: "settings",
+ title: t(s.labelKey, s.labelFallback),
+ subtitle: group ? `${group} · ${snippet}` : snippet,
+ iconName: "settings-rounded",
+ // Always below the weakest possible label/keyword match.
+ score: FUZZY_MIN_SCORE - 10,
+ onSelect: () => openSettings(s.key),
+ },
+ ];
+ });
+
+ return [...rows, ...sections, ...contentMatches]
+ .sort((a, b) => b.score - a.score)
+ .slice(0, limit);
+}
+
+export function rankProcessorResults(
+ trimmed: string,
+ t: TFunction,
+ gates: SuperSearchGates | null,
+ selectEntry: (entry: ProcessorSearchEntry) => void,
+ limit = GROUP_RESULT_CEILING,
+): SuperSearchResult[] {
+ if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return [];
+ // Only offer Processor pages to users who can actually enter that app.
+ if (!isProcessorGateOpen(gates)) return [];
+ return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [
+ (e) => t(e.labelKey, e.labelFallback),
+ (e) => e.labelFallback,
+ (e) => e.keywords?.join(" ") ?? "",
+ ])
+ .slice(0, limit)
+ .map(({ item, score }) => ({
+ key: `processor:${item.id}`,
+ group: "processor",
+ title: t(item.labelKey, item.labelFallback),
+ // Must exist in the bundled Material Symbols set (LocalIcon falls back
+ // to a network fetch for unknown names — blank when self-hosted offline).
+ iconName: "grid-view",
+ score,
+ onSelect: () => selectEntry(item),
+ }));
+}
+
+/**
+ * Orders the sources into the shared group layout, dropping empties. Hosts
+ * pass their own order so local results lead (the editor puts its own
+ * files/tools first and Processor pages last; the portal the reverse).
+ */
+export function assembleSuperSearchGroups(
+ byId: Partial>,
+ t: TFunction,
+ order: SuperSearchGroupId[] = GROUP_ORDER,
+): SuperSearchGroup[] {
+ const labels: Record = {
+ files: t("superSearch.group.files", "Files"),
+ tools: t("superSearch.group.tools", "Tools"),
+ settings: t("superSearch.group.settings", "Settings"),
+ processor: t("superSearch.group.processor", "Processor"),
+ };
+ return order
+ .map((id) => ({
+ id,
+ label: labels[id],
+ results: byId[id] ?? [],
+ }))
+ .filter((g) => g.results.length > 0);
+}
+
+/**
+ * The editor's results provider: the shared sources wired to in-app select
+ * actions (open file → viewer, select tool in the workbench, deep-link into
+ * the settings modal, route into the Processor).
+ *
+ * @param query current search text
+ * @param active whether the search surface is open; gates the My Files load
+ */
+export function useSuperSearch(
+ query: string,
+ active: boolean,
+ options?: SuperSearchQueryOptions,
+): UseSuperSearchResult {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const {
+ toolRegistry,
+ handleToolSelect,
+ handleToolSelectForced,
+ toolAvailability,
+ } = useToolWorkflow();
+ const { actions: navActions } = useNavigationActions();
+ const { actions: fileActions } = useFileActions();
+ // ViewerContext is only present once the viewer subtree mounts; treat as
+ // optional. Read through a ref: the provider value is rebuilt every viewer
+ // render, and a direct dependency would re-rank every result lane on each
+ // page turn — openFile only needs the value at click time.
+ const viewer = useContext(ViewerContext);
+ const viewerRef = useRef(viewer);
+ useEffect(() => {
+ viewerRef.current = viewer;
+ }, [viewer]);
+
+ const trimmed = query.trim();
+ const { stubs, loadingFiles } = useMyFilesStubs(active);
+ const { scopeEnabled } = useSearchScopeFilter(options);
+ const { pathname } = useLocation();
+
+ // Workbench-bound selections must leave the file manager through the router.
+ // Tool/file selection pins its URL via raw history.pushState, which the
+ // router never observes — so on /files the route keeps re-asserting the
+ // "myFiles" workbench and the selection appears to do nothing. Exit to the
+ // editor's home path: on processor-shipping builds "/" is a role router,
+ // not the editor.
+ const leaveFileManager = useCallback(() => {
+ if (pathname.startsWith("/files")) {
+ navigate(EDITOR_BASENAME);
+ }
+ }, [pathname, navigate]);
+
+ // --- Actions -----------------------------------------------------------
+ const openFile = useCallback(
+ async (stub: StirlingFileStub) => {
+ try {
+ // The file already lives in storage — load it as a stub so its id and
+ // metadata are preserved (addFiles would persist a duplicate record).
+ await fileActions.addStirlingFileStubs([stub], { selectFiles: true });
+ navActions.setWorkbench("viewer");
+ viewerRef.current?.setActiveFileId?.(stub.id);
+ leaveFileManager();
+ } catch (err) {
+ console.error("[SuperSearch] Failed to open file:", stub.name, err);
+ }
+ },
+ [fileActions, navActions, leaveFileManager],
+ );
+
+ const openTool = useCallback(
+ (id: ToolId) => {
+ // Link tools have no in-editor UI — selecting one shows a "tool not
+ // found" panel. Open their destination directly, matching how the
+ // editor's tool lists treat them.
+ const link = toolRegistry[id]?.link;
+ if (link) {
+ openExternalUrl(link);
+ return;
+ }
+ // Tools whose backend endpoint isn't served in this environment are
+ // flagged unavailable; handleToolSelect silently no-ops them. For a
+ // search ("take me to Repair") we still want the click to open the
+ // tool's UI, so fall back to the forced path for those. Available tools
+ // keep the normal path so the unsaved-changes guard still applies.
+ const available = toolAvailability[id]?.available !== false;
+ if (available) {
+ handleToolSelect(id);
+ } else {
+ handleToolSelectForced(id);
+ }
+ leaveFileManager();
+ },
+ [
+ handleToolSelect,
+ handleToolSelectForced,
+ toolAvailability,
+ toolRegistry,
+ leaveFileManager,
+ ],
+ );
+
+ const openSettings = useCallback(
+ (section: string, anchor?: string) => {
+ const path = anchor
+ ? `/settings/${section}?focus=${encodeURIComponent(anchor)}`
+ : `/settings/${section}`;
+ navigate(path);
+ },
+ [navigate],
+ );
+
+ const selectProcessorEntry = useCallback(
+ (item: ProcessorSearchEntry) => {
+ if (item.externalUrl) {
+ openExternalUrl(item.externalUrl);
+ } else {
+ navigate(item.path);
+ }
+ },
+ [navigate],
+ );
+
+ // --- Assemble ----------------------------------------------------------
+ const gates = useSuperSearchGates();
+
+ // Processor entities (users, policies, pipelines, sources) join the pages
+ // under the Processor section — same access gate as the pages group, each
+ // entity type filterable by its own scope.
+ const entityGroups = useProcessorEntityGroups(
+ trimmed,
+ active && trimmed.length > 0 && isProcessorGateOpen(gates),
+ t,
+ navigate,
+ scopeEnabled,
+ gates?.isAdmin ?? false,
+ );
+
+ const groups = useMemo(() => {
+ const assembledGroups = assembleSuperSearchGroups(
+ {
+ files: scopeEnabled("files")
+ ? rankFileResults(stubs, trimmed, openFile)
+ : [],
+ tools: scopeEnabled("tools")
+ ? rankToolResults(toolRegistry, trimmed, openTool)
+ : [],
+ settings: scopeEnabled("settings")
+ ? rankSettingsResults(trimmed, t, gates, openSettings)
+ : [],
+ processor: scopeEnabled("processor")
+ ? rankProcessorResults(trimmed, t, gates, selectProcessorEntry)
+ : [],
+ },
+ t,
+ );
+
+ // Section order: Editor first, Settings second, Processor last.
+ const processorSection = t("superSearch.group.processor", "Processor");
+ const sectionFor = (groupId: string): string => {
+ if (groupId === "processor") return processorSection;
+ if (groupId === "settings")
+ return t("superSearch.group.settings", "Settings");
+ return t("portal.nav.editor", "Editor");
+ };
+ return [
+ ...assembledGroups.map((group) => ({
+ ...group,
+ label:
+ group.id === "processor"
+ ? t("superSearch.group.pages", "Pages")
+ : group.label,
+ sectionLabel: sectionFor(group.id),
+ })),
+ ...entityGroups.map((group) => ({
+ ...group,
+ sectionLabel: processorSection,
+ })),
+ ];
+ }, [
+ stubs,
+ trimmed,
+ openFile,
+ toolRegistry,
+ openTool,
+ gates,
+ openSettings,
+ selectProcessorEntry,
+ scopeEnabled,
+ entityGroups,
+ t,
+ ]);
+
+ const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
+
+ return { groups, flatResults, loadingFiles };
+}
diff --git a/frontend/editor/src/core/hooks/useToolManagement.tsx b/frontend/editor/src/core/hooks/useToolManagement.tsx
index 87f9b2f889..951ead5f22 100644
--- a/frontend/editor/src/core/hooks/useToolManagement.tsx
+++ b/frontend/editor/src/core/hooks/useToolManagement.tsx
@@ -3,6 +3,7 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { usePreferences } from "@app/contexts/PreferencesContext";
import {
getAllEndpoints,
+ isComingSoonTool,
type ToolRegistryEntry,
type ToolRegistry,
} from "@app/data/toolsTaxonomy";
@@ -167,12 +168,7 @@ export const useToolManagement = (): ToolManagementResult => {
? availabilityInfo.available !== false
: true;
- // Check if tool is "coming soon" (has no component and no link)
- const isComingSoon =
- !baseTool.component &&
- !baseTool.link &&
- toolKey !== "read" &&
- toolKey !== "multiTool";
+ const isComingSoon = isComingSoonTool(toolKey, baseTool);
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
return;
diff --git a/frontend/editor/src/core/i18n/translationAudit.ts b/frontend/editor/src/core/i18n/translationAudit.ts
index 2a9bd33d6d..9dfbc5b21b 100644
--- a/frontend/editor/src/core/i18n/translationAudit.ts
+++ b/frontend/editor/src/core/i18n/translationAudit.ts
@@ -79,7 +79,8 @@ export const I18N_PROJECTS: TranslationProject[] = [
// SignSettings / SavedSignaturesSection resolve every key as
// t(`${scope}.${key}`); scope and leaf only ever exist as separate literals.
/^(sign|addText|addImage)\./,
- // SettingsSearchBar indexes whole subtrees via t(prefix, { returnObjects }).
+ // Super search's settings content matching (settingsContentSearch)
+ // indexes whole subtrees via t(prefix, { returnObjects }).
/^admin\.settings\./,
/^settings\./,
/^account\./,
diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx
index 88cbab8a7f..b1d8708649 100644
--- a/frontend/editor/src/core/pages/HomePage.tsx
+++ b/frontend/editor/src/core/pages/HomePage.tsx
@@ -599,11 +599,6 @@ const MyFilesSidebarOverrides = forwardRef(
{
- // Just focus the central search field; don't toggle collapse
- // (which on /files navigates back home).
- window.dispatchEvent(new Event("files-page:focus-search"));
- }}
onUploadFiles={handleUpload}
onPickGoogleDriveFiles={handleUpload}
extraAction={{
diff --git a/frontend/editor/src/core/tests/helpers/api-stubs.ts b/frontend/editor/src/core/tests/helpers/api-stubs.ts
index b1d792699b..d6ebfbe408 100644
--- a/frontend/editor/src/core/tests/helpers/api-stubs.ts
+++ b/frontend/editor/src/core/tests/helpers/api-stubs.ts
@@ -126,6 +126,10 @@ export interface MockAppApiOptions {
username?: string;
email?: string;
roles?: string[];
+ /** Spring role string (e.g. "ROLE_ADMIN") — drives `isAdmin` in the auth seam. */
+ role?: string;
+ /** Portal (Processor) access flag — gates the super search's Processor lanes. */
+ portalAccess?: boolean;
} | null;
/** Languages advertised by `/config/app-config`. */
languages?: string[];
diff --git a/frontend/editor/src/core/tests/live/edge-cases-security.spec.ts b/frontend/editor/src/core/tests/live/edge-cases-security.spec.ts
index 2e308ce3e2..f8021649b4 100644
--- a/frontend/editor/src/core/tests/live/edge-cases-security.spec.ts
+++ b/frontend/editor/src/core/tests/live/edge-cases-security.spec.ts
@@ -12,10 +12,9 @@ test.describe("20. Edge Cases and Security", () => {
test("should prevent XSS via search input", async ({ page }) => {
await loginAndSetup(page);
- // Step 1: Open the search box (the tool panel header shows a search
- // toggle; the field only mounts once it's pressed) and enter the payload
- await page.getByRole("button", { name: /search tools/i }).click();
- const searchBox = page.getByPlaceholder(/search|cari/i).first();
+ // Step 1: Enter the payload into the always-mounted super search bar
+ const searchBox = page.locator("#super-search-input");
+ await searchBox.click();
await searchBox.fill('">
');
// Step 2: Verify no script execution or image error handler fires
diff --git a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts
index ba33ea3c83..7c365eb1aa 100644
--- a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts
@@ -565,25 +565,6 @@ test.describe("Files page", () => {
test.describe("Side-rail integration with /files", () => {
test.use({ autoGoto: false });
- test("Rail Search focuses the central search field, no navigation", async ({
- page,
- }) => {
- await stubStorageApis(page);
- await seedFiles(page, [
- { id: "alpha", name: "alpha.pdf", remoteStorageId: null },
- ]);
- await gotoFilesPage(page);
- // Click the search row in the rail.
- await page.locator(".file-sidebar-search-row").click();
- // The central search input should be focused.
- const focused = await page.evaluate(
- () => document.activeElement?.getAttribute("aria-label") ?? "",
- );
- expect(focused).toMatch(/Search/i);
- // And we must still be on /files (i.e. didn't navigate home).
- await expect(page).toHaveURL(/\/files/);
- });
-
test("Rail New folder button visible on /files", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
diff --git a/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts b/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts
index 30ecc8d779..3e9d8a05ae 100644
--- a/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts
@@ -54,12 +54,11 @@ test.describe("13. Language / Localization", () => {
// Step 5: Wait for page reload (language change triggers window.location.reload())
await page.waitForLoadState("domcontentloaded");
- // Step 6: Verify the UI text is in English. The tool search is a
- // header toggle, so assert its English label rather than the field,
- // which only mounts once the toggle is pressed.
- await expect(
- page.getByRole("button", { name: /search tools/i }).first(),
- ).toBeVisible({ timeout: 10000 });
+ // Step 6: Verify the UI text is in English via the always-mounted
+ // super search bar's placeholder.
+ await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({
+ timeout: 10000,
+ });
}
});
});
diff --git a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts
index 4c44c77d98..5fa9037363 100644
--- a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts
@@ -17,14 +17,7 @@ test.describe("2. Main Dashboard / Home Page", () => {
page.locator('[data-testid="config-button"]').first(),
).toBeVisible();
- // Tool search sits behind a header toggle now, so assert the affordance
- // AND that pressing it actually mounts a usable search field — dropping
- // the second half would stop covering the input entirely.
- const searchToggle = page
- .getByRole("button", { name: /search tools/i })
- .first();
- await expect(searchToggle).toBeVisible();
- await searchToggle.click();
+ // Tool search lives in the global super search bar, always mounted.
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
await expect(
@@ -82,10 +75,8 @@ test.describe("2. Main Dashboard / Home Page", () => {
await page.goto("/editor");
- // Tool search is a header toggle; the field mounts only once pressed.
- await expect(
- page.getByRole("button", { name: /search tools/i }).first(),
- ).toBeVisible();
+ // Tool search lives in the global super search bar, always mounted.
+ await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
});
});
diff --git a/frontend/editor/src/core/tests/stubbed/super-search.spec.ts b/frontend/editor/src/core/tests/stubbed/super-search.spec.ts
new file mode 100644
index 0000000000..431a98b421
--- /dev/null
+++ b/frontend/editor/src/core/tests/stubbed/super-search.spec.ts
@@ -0,0 +1,443 @@
+import { test, expect } from "@app/tests/helpers/stub-test-base";
+import { openSettings } from "@app/tests/helpers/ui-helpers";
+import type { Page } from "@playwright/test";
+
+/**
+ * Super search E2E: the bar itself (open, filter, select, show-more, XSS
+ * hygiene) plus the access gating — a user without Processor access must see
+ * no Processor chips, no Processor results, and trigger no entity fetches.
+ *
+ * Portal-lane presence differs by build: `vite dev` ships the portal, the CI
+ * preview build does not. Gate-closed assertions hold in both (closed lanes
+ * look identical to absent ones); gate-open lane assertions skip themselves
+ * when the build ships no portal.
+ */
+
+const INPUT = "#super-search-input";
+
+/**
+ * URLs only the search's Processor entity fetch hits. `/api/v1/policies`
+ * itself is deliberately absent: the editor's policy auto-run also reads it at
+ * boot for every user, so it can't distinguish a search leak.
+ */
+const ENTITY_API_PATTERN =
+ /\/api\/v1\/policies\/overview|\/api\/v1\/sources|\/api\/v1\/proprietary\/ui-data\/admin-settings/;
+
+async function openSearch(page: Page) {
+ const input = page.locator(INPUT);
+ await input.click();
+ await expect(input).toHaveAttribute("aria-expanded", "true");
+ return input;
+}
+
+test.describe("Super search — bar basics", () => {
+ test("Ctrl+K opens the bar and results filter as you type", async ({
+ page,
+ }) => {
+ const input = page.locator(INPUT);
+ // The shortcut listener mounts with the bar — wait for it before pressing.
+ await expect(input).toBeVisible();
+ await page.keyboard.press("Control+KeyK");
+ await expect(input).toBeFocused();
+ await expect(input).toHaveAttribute("aria-expanded", "true");
+
+ await input.fill("merge");
+ await expect(
+ page.getByRole("option", { name: /Merge/ }).first(),
+ ).toBeVisible();
+
+ // A different query replaces the results in place.
+ await input.fill("compress");
+ await expect(
+ page.getByRole("option", { name: /Compress/ }).first(),
+ ).toBeVisible();
+ await expect(page.getByRole("option", { name: /^Merge/ })).toHaveCount(0);
+ });
+
+ test("selecting a tool result opens that tool", async ({ page }) => {
+ const input = await openSearch(page);
+ await input.fill("merge");
+ await page
+ .getByRole("option", { name: /^Merge/ })
+ .first()
+ .click();
+ await page.waitForURL("**/merge**");
+ });
+
+ test("shows the empty state for a query with no matches", async ({
+ page,
+ }) => {
+ const input = await openSearch(page);
+ await input.fill("xyznonexistent123");
+ await expect(page.getByText("No results found")).toBeVisible();
+ });
+
+ test("treats markup in the query as plain text", async ({ page }) => {
+ let dialogFired = false;
+ page.on("dialog", (dialog) => {
+ dialogFired = true;
+ void dialog.dismiss();
+ });
+
+ const input = await openSearch(page);
+ const payload =
+ '
';
+ await input.fill(payload);
+ await expect(input).toHaveValue(payload);
+
+ await expect(page.getByText("No results found")).toBeVisible();
+ expect(dialogFired).toBe(false);
+ expect(
+ await page.evaluate(
+ () => (window as unknown as { __xss?: number }).__xss,
+ ),
+ ).toBeUndefined();
+ });
+
+ test("show more reveals the rest of a large group and collapses again", async ({
+ page,
+ }) => {
+ const input = await openSearch(page);
+ // Broad query — the tools lane alone exceeds the 5-row collapsed slice.
+ // Assertions scope to the Tools group: other groups (docs, entities)
+ // pop in asynchronously, so page-wide option counts are racy.
+ await input.fill("pdf");
+ const tools = page.getByRole("group", { name: "Tools" });
+ await expect(tools.getByRole("option").first()).toBeVisible();
+
+ const collapsedCount = await tools.getByRole("option").count();
+ const showMore = tools.getByRole("button", { name: /Show \d+ more/ });
+ await expect(showMore).toBeVisible();
+ const hidden = Number(
+ (await showMore.innerText()).match(/\d+/)?.[0] ?? "0",
+ );
+ await showMore.click();
+
+ await expect(tools.getByRole("option")).toHaveCount(
+ collapsedCount + hidden,
+ );
+ const showLess = tools.getByRole("button", { name: "Show less" });
+ await expect(showLess).toBeVisible();
+
+ await showLess.click();
+ await expect(tools.getByRole("option")).toHaveCount(collapsedCount);
+ });
+
+ test("selecting a tool from the /files page leaves the file manager", async ({
+ page,
+ }) => {
+ await page.goto("/files");
+ const input = page.locator(INPUT).first();
+ await expect(input).toBeVisible({ timeout: 15000 });
+ await input.click();
+ await input.fill("merge");
+ await page
+ .getByRole("option", { name: /^Merge/ })
+ .first()
+ .click();
+
+ // The selection pins its tool URL via raw history.pushState, which the
+ // router never sees — the sink must leave /files through the router or
+ // the myFiles workbench swallows the selection (and "/" is a role router
+ // on processor-shipping builds, so the exit must target the editor home).
+ await expect(page.locator(".files-page-header-search")).not.toBeVisible({
+ timeout: 5000,
+ });
+ await expect(page).toHaveURL(/\/merge/);
+ });
+
+ test("Ctrl+K inside the settings modal closes it and focuses the bar", async ({
+ page,
+ }) => {
+ const input = page.locator(INPUT);
+ await expect(input).toBeVisible();
+ await openSettings(page);
+
+ // The modal traps focus, so the bar's own shortcut is inert; the modal
+ // cedes: it closes itself and hands focus to the bar.
+ await page.keyboard.press("Control+KeyK");
+ await expect(page.locator(".modal-container")).not.toBeVisible();
+ await expect(input).toBeFocused();
+ await expect(input).toHaveAttribute("aria-expanded", "true");
+
+ // Full loop: a settings result deep-links straight back into the modal.
+ await input.fill("general");
+ await page
+ .getByRole("option", { name: /General/ })
+ .first()
+ .click();
+ await expect(page.locator(".modal-container")).toBeVisible();
+ });
+});
+
+test.describe("Super search — user without Processor access", () => {
+ // Login enabled, signed in as a plain member: no admin role, no portal
+ // access. The Processor gate must stay closed.
+ test.use({
+ stubOptions: {
+ enableLogin: true,
+ user: {
+ id: 33,
+ username: "bob",
+ email: "bob@example.com",
+ role: "ROLE_USER",
+ portalAccess: false,
+ },
+ },
+ seedJwt: true,
+ });
+
+ test("sees no Processor chips, results or entity fetches", async ({
+ page,
+ }) => {
+ const entityRequests: string[] = [];
+ page.on("request", (request) => {
+ if (ENTITY_API_PATTERN.test(request.url())) {
+ entityRequests.push(request.url());
+ }
+ });
+
+ const input = await openSearch(page);
+
+ // Chip row: the editor lanes only. No Processor lanes of any kind.
+ await expect(page.getByRole("button", { name: "Tools" })).toBeVisible();
+ for (const lane of [
+ "Pages",
+ "Users",
+ "Policies",
+ "Pipelines",
+ "Sources",
+ "Docs",
+ ]) {
+ await expect(
+ page.getByRole("button", { name: lane, exact: true }),
+ ).toHaveCount(0);
+ }
+
+ // A query that would hit policies/docs/admin settings when the gate is
+ // open must yield no Processor section for this user.
+ await input.fill("security");
+ await expect(page.getByRole("option").first()).toBeVisible();
+ await expect(
+ page.locator(".super-search-section-label", { hasText: "Processor" }),
+ ).toHaveCount(0);
+
+ // And the search must not have fetched any entity data on their behalf.
+ await page.waitForTimeout(750);
+ expect(entityRequests).toEqual([]);
+ });
+
+ test("sees login-gated settings but no admin settings", async ({ page }) => {
+ const input = await openSearch(page);
+
+ // Positive control — requiresLogin sections are visible to a signed-in
+ // user, proving the settings lane itself works for this account.
+ await input.fill("account");
+ await expect(
+ page.getByRole("option", { name: /Account Settings/ }).first(),
+ ).toBeVisible();
+
+ // Admin-only sections stay hidden (label and content matches alike).
+ await input.fill("endpoints");
+ await expect(page.getByRole("option", { name: /^Endpoints/ })).toHaveCount(
+ 0,
+ );
+ });
+});
+
+test.describe("Super search — portal access without admin", () => {
+ // Self-hosted grants portal access beyond admins (team owners, ACL
+ // grantees), but the users roster endpoint is admin-only — the Users lane
+ // must not be offered to a session the endpoint would always refuse.
+ test.use({
+ stubOptions: {
+ enableLogin: true,
+ user: {
+ id: 44,
+ username: "owner",
+ email: "owner@example.com",
+ role: "ROLE_USER",
+ portalAccess: true,
+ },
+ },
+ seedJwt: true,
+ });
+
+ test("sees Processor lanes but no Users chip and no roster fetch", async ({
+ page,
+ }) => {
+ const rosterRequests: string[] = [];
+ page.on("request", (request) => {
+ if (
+ request.url().includes("/api/v1/proprietary/ui-data/admin-settings")
+ ) {
+ rosterRequests.push(request.url());
+ }
+ });
+ for (const [pattern, json] of [
+ ["**/api/v1/policies", []],
+ ["**/api/v1/policies/runs", []],
+ ["**/api/v1/policies/overview", { pipelines: [] }],
+ ["**/api/v1/sources", { sources: [] }],
+ ] as const) {
+ await page.route(pattern, (route) => route.fulfill({ json }));
+ }
+
+ const input = await openSearch(page);
+ const portalShips =
+ (await page.getByRole("button", { name: "Pages", exact: true }).count()) >
+ 0;
+ test.skip(!portalShips, "this build ships no portal — no lanes to gate");
+
+ for (const lane of ["Policies", "Pipelines", "Sources"]) {
+ await expect(
+ page.getByRole("button", { name: lane, exact: true }),
+ ).toBeVisible();
+ }
+ await expect(
+ page.getByRole("button", { name: "Users", exact: true }),
+ ).toHaveCount(0);
+
+ // A query that used to fire the doomed roster fetch once per TTL.
+ await input.fill("admin");
+ await expect(page.locator(".super-search-dropdown").first()).toBeVisible();
+ await page.waitForTimeout(1500);
+ expect(rosterRequests).toEqual([]);
+ });
+});
+
+test.describe("Super search — admin with Processor access", () => {
+ test.use({
+ stubOptions: {
+ enableLogin: true,
+ isAdmin: true,
+ user: {
+ id: 1,
+ username: "admin",
+ email: "admin@example.com",
+ role: "ROLE_ADMIN",
+ portalAccess: true,
+ },
+ },
+ seedJwt: true,
+ });
+
+ test("sees Processor lanes and live entity results", async ({ page }) => {
+ // Entity data the gate-open bar fetches, stubbed with one source row.
+ await page.route("**/api/v1/policies", (route) =>
+ route.fulfill({ json: [] }),
+ );
+ await page.route("**/api/v1/policies/runs", (route) =>
+ route.fulfill({ json: [] }),
+ );
+ await page.route("**/api/v1/policies/overview", (route) =>
+ route.fulfill({ json: { pipelines: [] } }),
+ );
+ await page.route("**/api/v1/sources", (route) =>
+ route.fulfill({
+ json: {
+ sources: [{ id: "src-1", name: "Contract Intake", type: "email" }],
+ },
+ }),
+ );
+
+ await openSearch(page);
+
+ // The Processor lanes only exist in builds that ship the portal (dev,
+ // VITE_INCLUDE_PORTAL) — the CI preview build has none to show.
+ const portalShips =
+ (await page.getByRole("button", { name: "Pages", exact: true }).count()) >
+ 0;
+ test.skip(!portalShips, "this build ships no portal — no lanes to gate");
+
+ for (const lane of ["Users", "Policies", "Pipelines", "Sources"]) {
+ await expect(
+ page.getByRole("button", { name: lane, exact: true }),
+ ).toBeVisible();
+ }
+
+ const input = page.locator(INPUT);
+ await input.fill("contract intake");
+ await expect(
+ page.getByRole("option", { name: /Contract Intake/ }).first(),
+ ).toBeVisible();
+ await expect(
+ page
+ .locator(".super-search-section-label", { hasText: "Processor" })
+ .first(),
+ ).toBeVisible();
+ });
+});
+
+test.describe("Portal bar — tool results hop into the editor", () => {
+ test.use({
+ stubOptions: {
+ enableLogin: true,
+ isAdmin: true,
+ user: {
+ id: 1,
+ username: "admin",
+ email: "admin@example.com",
+ role: "ROLE_ADMIN",
+ portalAccess: true,
+ },
+ },
+ seedJwt: true,
+ autoGoto: false,
+ });
+
+ test("selecting a tool routes client-side, not via a full page load", async ({
+ page,
+ }) => {
+ for (const [pattern, json] of [
+ ["**/api/v1/policies", []],
+ ["**/api/v1/policies/runs", []],
+ ["**/api/v1/policies/overview", { pipelines: [] }],
+ ["**/api/v1/sources", { sources: [] }],
+ ["**/api/v1/team/my", []],
+ ] as const) {
+ await page.route(pattern, (route) => route.fulfill({ json }));
+ }
+
+ await page.goto("/processor");
+ const input = page.locator("#portal-search-input");
+ // The portal only ships in dev / VITE_INCLUDE_PORTAL builds — on the CI
+ // preview build /processor falls through to the editor and there is no
+ // portal bar to hop from.
+ const portalShips = await input
+ .waitFor({ state: "visible", timeout: 20000 })
+ .then(() => true)
+ .catch(() => false);
+ test.skip(!portalShips, "this build ships no portal — no bar to hop from");
+
+ // A full page load would drop this marker — and on bundled deploys it
+ // would also 401: document GETs carry no Authorization header, so the
+ // backend bounces them to /login even with a live session.
+ await page.evaluate(() => {
+ (window as unknown as { __spaMarker?: boolean }).__spaMarker = true;
+ });
+
+ await input.click();
+ await input.fill("merge");
+ await page
+ .getByRole("option", { name: /^Merge/ })
+ .first()
+ .click();
+
+ await expect(page).toHaveURL(/\/merge/);
+ // Editor mounted: its own search bar replaces the portal's.
+ await expect(page.locator("#super-search-input")).toBeVisible({
+ timeout: 15000,
+ });
+ // The editor's URL-driven tool init must run on this route-swap mount,
+ // not just on a cold page load: the Merge tool panel actually opens.
+ await expect(
+ page.getByRole("button", { name: "Merge", exact: true }),
+ ).toBeVisible({ timeout: 10000 });
+ expect(
+ await page.evaluate(
+ () => (window as unknown as { __spaMarker?: boolean }).__spaMarker,
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts b/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts
deleted file mode 100644
index 7dfda74b79..0000000000
--- a/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import type { Page } from "@playwright/test";
-import { test, expect } from "@app/tests/helpers/stub-test-base";
-
-/**
- * The tool panel header shows a search *toggle*; the field only mounts once
- * it's pressed. Open it and hand back the focused input.
- */
-async function openToolSearch(page: Page) {
- await page.getByRole("button", { name: /search tools/i }).click();
- const searchBox = page.getByPlaceholder(/search|cari/i).first();
- await expect(searchBox).toBeVisible({ timeout: 5000 });
- return searchBox;
-}
-
-test.describe("3. Tool Search", () => {
- test.describe("3.1 Search - Happy Path", () => {
- test("should filter tools in real time based on search input", async ({
- page,
- }) => {
- // Step 1: Open the search box from the header toggle
- const searchBox = await openToolSearch(page);
-
- // Step 2: Type "merge"
- await searchBox.fill("merge");
-
- // Step 3: Verify search results filter to show relevant tools
- await expect(
- page.locator("text=/merge|menggabungkan/i").first(),
- ).toBeVisible({ timeout: 5000 });
-
- // Step 5: Clear the search field
- await searchBox.clear();
-
- // Step 6: Verify all tools reappear (check for multiple categories)
- await expect(
- page.locator("text=/recommended|direkomendasikan/i").first(),
- ).toBeVisible({ timeout: 5000 });
- });
- });
-
- test.describe("3.2 Search - No Results", () => {
- test("should handle queries with no matching tools gracefully", async ({
- page,
- }) => {
- // Step 1: Open the search box from the header toggle
- const searchBox = await openToolSearch(page);
-
- // Step 2: Type xyznonexistent123
- await searchBox.fill("xyznonexistent123");
-
- // Step 3: Verify the search field accepted the input
- await expect(searchBox).toHaveValue("xyznonexistent123");
-
- // The app uses fuzzy search with a fallback that shows all tools when nothing
- // matches, so we verify the search state is active (no "recommended" section)
- // and the page remains functional without errors.
- await expect(
- page.locator("text=/recommended|direkomendasikan/i"),
- ).toHaveCount(0, { timeout: 5000 });
-
- // Step 4: Clear the search field
- await searchBox.clear();
-
- // Step 5: Verify all tools reappear (recommended section comes back)
- await expect(
- page.locator("text=/recommended|direkomendasikan/i").first(),
- ).toBeVisible({ timeout: 5000 });
- });
- });
-
- test.describe("3.3 Search - Special Characters", () => {
- test("should sanitize search input against XSS", async ({ page }) => {
- // Step 1: Type XSS payload into the search box
- const searchBox = await openToolSearch(page);
- await searchBox.fill("");
-
- // Step 2: Verify no script execution occurs (no alert dialog)
- // If an alert appeared, Playwright would throw an unhandled dialog error
- await page.waitForTimeout(1000);
-
- // Step 3: Verify the search treats the input as plain text
- await expect(searchBox).toHaveValue("");
-
- // Step 4: Clear the search field
- await searchBox.clear();
- });
- });
-});
diff --git a/frontend/editor/src/core/types/superSearch.ts b/frontend/editor/src/core/types/superSearch.ts
new file mode 100644
index 0000000000..3d8e72849f
--- /dev/null
+++ b/frontend/editor/src/core/types/superSearch.ts
@@ -0,0 +1,134 @@
+import type React from "react";
+
+/**
+ * The super search's shared contract: what a results provider returns and the
+ * shapes hosts exchange with the shared rankers. Pure types, kept apart from
+ * the provider implementations so leaf modules (flavor seams, the portal's
+ * entity search) can depend on the contract without importing a provider —
+ * which would be a circular import.
+ */
+
+export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor";
+
+export interface SuperSearchResult {
+ /** Stable unique key across all groups. */
+ key: string;
+ /** Group id — the editor uses SuperSearchGroupId; other hosts use their own. */
+ group: string;
+ title: string;
+ subtitle?: string;
+ /** LocalIcon name (files/settings); tools provide a React node via `icon`. */
+ iconName?: string;
+ icon?: React.ReactNode;
+ score: number;
+ onSelect: () => void | Promise;
+}
+
+export interface SuperSearchGroup {
+ id: string;
+ label: string;
+ /** Optional higher-level section label rendered above consecutive groups. */
+ sectionLabel?: string;
+ results: SuperSearchResult[];
+}
+
+export interface SuperSearchScope {
+ id: string;
+ label: string;
+ aliases?: string[];
+}
+
+export interface UseSuperSearchResult {
+ /** Non-empty groups, in display order. */
+ groups: SuperSearchGroup[];
+ /** All results flattened in display order (for keyboard navigation). */
+ flatResults: SuperSearchResult[];
+ /** True while the My Files store is loading for the first time. */
+ loadingFiles: boolean;
+}
+
+export interface SuperSearchQueryOptions {
+ scopeIds?: readonly string[];
+}
+
+/**
+ * Visibility gates shared by the settings and Processor sources. Hosts pass
+ * `null` while the app config is still loading — the rankers treat that as
+ * "most restrictive" so gated results can appear once config lands but never
+ * flash open before it.
+ */
+export interface SuperSearchGates {
+ isAdmin: boolean;
+ loginEnabled: boolean;
+ portalAccessible?: boolean;
+ /**
+ * Whether no-login mode keeps the read-only admin settings preview
+ * (`system.showSettingsWhenNoLogin`, default true). Mirrors the settings
+ * nav builder's admin gate: `isAdmin || (!loginEnabled && this)`.
+ */
+ showSettingsWhenNoLogin?: boolean;
+ /**
+ * True for a signed-out (anonymous) session. Gates account-bound sections
+ * on hosts where auth state, not the local backend config, is the truth
+ * (SaaS). Hosts whose users are always signed in may omit it.
+ */
+ isAnonymous?: boolean;
+}
+
+/**
+ * The Processor's entity lanes as scope definitions — ids, the portal view
+ * each targets, chip labels and typed-prefix aliases. Both hosts' chip lists
+ * and the portal's entity module derive from this one list, so the chips
+ * can't drift apart; it lives in the types leaf because the editor's chip
+ * list must not import portal code.
+ */
+export interface PortalEntityScopeDef {
+ id:
+ | "portal-users"
+ | "portal-policies"
+ | "portal-pipelines"
+ | "portal-sources";
+ /** Portal view id the scope targets (visibility check vs the page index). */
+ viewId: string;
+ labelKey: string;
+ labelFallback: string;
+ aliases: readonly string[];
+}
+
+/**
+ * The developer-docs scope. Separate from the entity defs above: docs are a
+ * bundled full-text manifest, not a fetched entity list, so they don't ride
+ * the fetch cache — but they get a chip and a results group like the rest.
+ */
+export const PORTAL_DOCS_SCOPE_ID = "portal-docs";
+
+export const PORTAL_ENTITY_SCOPE_DEFS: readonly PortalEntityScopeDef[] = [
+ {
+ id: "portal-users",
+ viewId: "users",
+ labelKey: "portal.nav.users",
+ labelFallback: "Users",
+ aliases: ["user", "users", "member", "members"],
+ },
+ {
+ id: "portal-policies",
+ viewId: "policies",
+ labelKey: "portal.nav.policies",
+ labelFallback: "Policies",
+ aliases: ["policy", "policies"],
+ },
+ {
+ id: "portal-pipelines",
+ viewId: "pipelines",
+ labelKey: "portal.nav.pipelines",
+ labelFallback: "Pipelines",
+ aliases: ["pipeline", "pipelines"],
+ },
+ {
+ id: "portal-sources",
+ viewId: "sources",
+ labelKey: "portal.nav.sources",
+ labelFallback: "Sources",
+ aliases: ["source", "sources"],
+ },
+];
diff --git a/frontend/editor/src/core/utils/fuzzySearch.ts b/frontend/editor/src/core/utils/fuzzySearch.ts
index 49e4a11e56..5354fc091f 100644
--- a/frontend/editor/src/core/utils/fuzzySearch.ts
+++ b/frontend/editor/src/core/utils/fuzzySearch.ts
@@ -81,9 +81,14 @@ export function scoreMatch(queryRaw: string, targetRaw: string): number {
return best;
}
+/** Lowest score any fuzzy match can pass with (see minScoreForQuery). Rankers
+ * that mix fuzzy results with fixed-score entries key off this to stay below
+ * every real match. */
+export const FUZZY_MIN_SCORE = 30;
+
export function minScoreForQuery(query: string): number {
const len = normalizeText(query).length;
- return len <= 3 ? 40 : 30;
+ return len <= 3 ? 40 : FUZZY_MIN_SCORE;
}
// Decide if a target matches a query based on a threshold
diff --git a/frontend/editor/src/core/utils/safeNavigation.ts b/frontend/editor/src/core/utils/safeNavigation.ts
new file mode 100644
index 0000000000..ca19bc32cc
--- /dev/null
+++ b/frontend/editor/src/core/utils/safeNavigation.ts
@@ -0,0 +1,39 @@
+/**
+ * Guarded client-side navigation for URLs that arrive as data (tool registry
+ * links, search index entries, flavor config) rather than literals. The
+ * values are trusted today, but funnelling them through a scheme allowlist
+ * means a poisoned or mistyped entry (`javascript:…`) can never become a
+ * script-execution sink.
+ */
+
+const SAFE_PROTOCOLS = new Set(["http:", "https:"]);
+
+/** The URL resolved against the current origin, or null if unparseable or a
+ * non-web scheme (javascript:, data:, …). */
+export function toSafeWebUrl(url: string): URL | null {
+ let parsed: URL;
+ try {
+ parsed = new URL(url, window.location.origin);
+ } catch {
+ return null;
+ }
+ return SAFE_PROTOCOLS.has(parsed.protocol) ? parsed : null;
+}
+
+/** window.open in a new tab, dropped silently for non-web schemes. */
+export function openExternalUrl(url: string): void {
+ const safe = toSafeWebUrl(url);
+ if (safe) window.open(safe.href, "_blank", "noopener,noreferrer");
+}
+
+/**
+ * Full-page navigation (window.location), dropped for non-web schemes. The
+ * guard is scheme-only: cross-origin http(s) targets pass by design (a
+ * separately-hosted editor is configured build-time via VITE_EDITOR_URL), so
+ * callers must pass build/config values — never user-supplied input, which
+ * could redirect anywhere on the web.
+ */
+export function assignLocation(url: string): void {
+ const safe = toSafeWebUrl(url);
+ if (safe) window.location.assign(safe.href);
+}
diff --git a/frontend/editor/src/desktop/data/processorEntitySearch.ts b/frontend/editor/src/desktop/data/processorEntitySearch.ts
new file mode 100644
index 0000000000..24f752492e
--- /dev/null
+++ b/frontend/editor/src/desktop/data/processorEntitySearch.ts
@@ -0,0 +1,6 @@
+/**
+ * Desktop inherits proprietary's app but must NOT ship the portal (see the
+ * admin-route seam) — shadow the entity search back to core's empty stub so
+ * the super search never fetches or offers Processor entities on desktop.
+ */
+export { useProcessorEntityGroups } from "@core/data/processorEntitySearch";
diff --git a/frontend/editor/src/desktop/data/processorSearchIndex.ts b/frontend/editor/src/desktop/data/processorSearchIndex.ts
new file mode 100644
index 0000000000..56cfb84a0d
--- /dev/null
+++ b/frontend/editor/src/desktop/data/processorSearchIndex.ts
@@ -0,0 +1,10 @@
+/**
+ * Desktop inherits proprietary's app but must NOT ship the portal (see the
+ * admin-route seam) — shadow the index back to core's empty list so the super
+ * search never offers Processor destinations that don't exist on desktop.
+ */
+export type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
+export {
+ PROCESSOR_SEARCH_INDEX,
+ isPortalEntityScopeAccessible,
+} from "@core/data/processorSearchIndex";
diff --git a/frontend/editor/src/desktop/data/settingsSectionRegistry.ts b/frontend/editor/src/desktop/data/settingsSectionRegistry.ts
new file mode 100644
index 0000000000..dc55c927be
--- /dev/null
+++ b/frontend/editor/src/desktop/data/settingsSectionRegistry.ts
@@ -0,0 +1,50 @@
+import { type SettingsSectionEntry } from "@core/data/settingsSectionRegistry";
+
+export type { SettingsSectionEntry };
+
+/**
+ * Desktop settings sections. The desktop modal reflows heavily by connection
+ * mode (`configNavSections`): local mode shows only Preferences + Connection
+ * Mode + Legal, while SaaS mode swaps in the cloud Plan/Team sections and hides
+ * the self-hosted admin area. To stay safe across both modes without threading
+ * the (async) connection state into the always-mounted search, this lists only
+ * the sections guaranteed to render in every desktop mode.
+ *
+ * Cloud Plan/Team search on desktop-SaaS is a deliberate Tier-0 gap, not a
+ * regression — better to omit them than to deep-link to a dead tab in local
+ * mode.
+ */
+export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
+ {
+ key: "general",
+ labelKey: "settings.general.title",
+ labelFallback: "General",
+ keywords: ["theme", "language", "appearance", "preferences", "startup"],
+ groupLabelKey: "settings.preferences.title",
+ groupLabelFallback: "Preferences",
+ },
+ {
+ key: "hotkeys",
+ labelKey: "settings.hotkeys.title",
+ labelFallback: "Keyboard Shortcuts",
+ keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
+ groupLabelKey: "settings.preferences.title",
+ groupLabelFallback: "Preferences",
+ },
+ {
+ key: "connectionMode",
+ labelKey: "settings.connection.title",
+ labelFallback: "Connection Mode",
+ keywords: ["connection", "local", "cloud", "server", "sign in"],
+ groupLabelKey: "settings.connection.title",
+ groupLabelFallback: "Connection Mode",
+ },
+ {
+ key: "legal",
+ labelKey: "settings.legal.label",
+ labelFallback: "Legal",
+ keywords: ["legal", "terms", "privacy", "licenses"],
+ groupLabelKey: "settings.legal.title",
+ groupLabelFallback: "Legal",
+ },
+];
diff --git a/frontend/editor/src/portal/api/search.ts b/frontend/editor/src/portal/api/search.ts
deleted file mode 100644
index d3fc672577..0000000000
--- a/frontend/editor/src/portal/api/search.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { apiClient } from "@portal/api/http";
-
-export interface QuickAction {
- group: "Jump to" | "Create" | "Theme";
- label: string;
- /** Keyboard hint shown to the right. */
- hint: string;
-}
-
-/** GET /v1/search/quick-actions */
-export async function fetchQuickActions(): Promise {
- return apiClient.local.json("/v1/search/quick-actions");
-}
diff --git a/frontend/editor/src/portal/api/usersCapabilities.ts b/frontend/editor/src/portal/api/usersCapabilities.ts
index dcea5487ec..01f68ae283 100644
--- a/frontend/editor/src/portal/api/usersCapabilities.ts
+++ b/frontend/editor/src/portal/api/usersCapabilities.ts
@@ -56,4 +56,12 @@ export interface UsersCapabilities {
manageGrants: boolean;
/** Whether "remove" takes the member out of the whole org or just the team. */
removeScope: "org" | "team";
+ /**
+ * Listing the roster needs the org-admin role. Self-hosted reads the
+ * admin-only endpoints (`@PreAuthorize(hasRole('ADMIN'))`), which refuse
+ * the non-admin sessions portal access also admits (team owners, ACL
+ * grantees). SaaS lists through the team-leader endpoints, which every
+ * portal-eligible session can call.
+ */
+ listingRequiresAdmin: boolean;
}
diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css
index 1657b56bdf..d3f1332969 100644
--- a/frontend/editor/src/portal/components/AppShell.css
+++ b/frontend/editor/src/portal/components/AppShell.css
@@ -42,8 +42,8 @@
align-items: center;
gap: 0.375rem;
padding: 0 0.625rem;
- background: var(--color-sidebar-bg);
- border-bottom: 1px solid var(--color-sidebar-border);
+ background: var(--c-bg-raised);
+ border-bottom: 1px solid var(--c-border-subtle);
}
.portal-shell__topbar-wordmark {
diff --git a/frontend/editor/src/portal/components/AppShell.stories.tsx b/frontend/editor/src/portal/components/AppShell.stories.tsx
index 1df98c3197..a15488c48b 100644
--- a/frontend/editor/src/portal/components/AppShell.stories.tsx
+++ b/frontend/editor/src/portal/components/AppShell.stories.tsx
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { AppShell } from "@portal/components/AppShell";
import { Home } from "@portal/views/Home";
@@ -12,6 +13,14 @@ const meta: Meta = {
title: "Portal/Shell/AppShell",
component: AppShell,
parameters: { layout: "fullscreen" },
+ decorators: [
+ // The shell hosts the portal search bar, which reads the tool registry.
+ (Story) => (
+
+
+
+ ),
+ ],
};
export default meta;
type Story = StoryObj;
diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx
index 77c8f90686..1811fa02c4 100644
--- a/frontend/editor/src/portal/components/AppShell.tsx
+++ b/frontend/editor/src/portal/components/AppShell.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { ActionIcon } from "@app/ui";
import { Sidebar } from "@portal/components/Sidebar";
+import { PortalSearchBar } from "@portal/components/PortalSearchBar";
import { useUI } from "@portal/contexts/UIContext";
import { MenuIcon, SearchIcon } from "@portal/components/icons";
import { Logo } from "@app/ui/Logo";
@@ -10,12 +11,12 @@ import "@portal/components/AppShell.css";
/**
* Compact header shown only under the mobile breakpoint (CSS-hidden on
- * desktop): hamburger opens the sidebar drawer, search opens the palette
- * (there's no ⌘K on a phone).
+ * desktop): hamburger opens the sidebar drawer, search focuses the global
+ * search bar below (there's no ⌘K on a phone).
*/
function MobileTopbar() {
const { t } = useTranslation();
- const { mobileNavOpen, toggleMobileNav, openSearch } = useUI();
+ const { mobileNavOpen, toggleMobileNav, closeMobileNav } = useUI();
return (
{
+ closeMobileNav();
+ document.getElementById("portal-search-input")?.focus();
+ }}
>
@@ -47,9 +51,10 @@ function MobileTopbar() {
/**
* Two-column layout: fixed-width sidebar on the left, a scrolling main column on
- * the right. Under the mobile breakpoint the sidebar becomes an off-canvas
- * drawer behind a scrim, opened from the topbar hamburger. The Sidebar reads
- * its state from context, so this shell stays prop-free.
+ * the right (topped by the global search bar). Under the mobile breakpoint the
+ * sidebar becomes an off-canvas drawer behind a scrim, opened from the topbar
+ * hamburger. The Sidebar reads its state from context, so this shell stays
+ * prop-free.
*/
export function AppShell({ children }: { children: ReactNode }) {
const { mobileNavOpen, closeMobileNav } = useUI();
@@ -83,6 +88,7 @@ export function AppShell({ children }: { children: ReactNode }) {
)}
diff --git a/frontend/editor/src/portal/components/PortalChrome.tsx b/frontend/editor/src/portal/components/PortalChrome.tsx
index 1262c84b8e..87afd094cc 100644
--- a/frontend/editor/src/portal/components/PortalChrome.tsx
+++ b/frontend/editor/src/portal/components/PortalChrome.tsx
@@ -1,39 +1,11 @@
-import { useEffect } from "react";
import { useLocation } from "react-router-dom";
+import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
-import { useUI } from "@portal/contexts/UIContext";
import { AppShell } from "@portal/components/AppShell";
-import { SearchModal } from "@portal/components/SearchModal";
import { PortalSettingsHost } from "@portal/components/PortalSettingsHost";
import { ViewRouter } from "@portal/ViewRouter";
-/**
- * Global keyboard shortcuts. Lives below the UIProvider so it can dispatch into
- * the overlay state. Currently just ⌘K / Ctrl+K to toggle the search palette.
- */
-function GlobalShortcuts() {
- const { toggleSearch, closeSearch } = useUI();
-
- useEffect(() => {
- function onKey(e: KeyboardEvent) {
- const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
- if (isCmdK) {
- e.preventDefault();
- toggleSearch();
- return;
- }
- if (e.key === "Escape") {
- closeSearch();
- }
- }
- document.addEventListener("keydown", onKey);
- return () => document.removeEventListener("keydown", onKey);
- }, [toggleSearch, closeSearch]);
-
- return null;
-}
-
/**
* The routed view, wrapped in an error boundary so a single view crashing can't
* white-screen the portal (the shell + nav stay alive). Keyed by route so
@@ -49,24 +21,24 @@ function RoutedContent() {
}
/**
- * The flavor-agnostic portal chrome: the shell (sidebar + header + routed view)
- * plus the global overlays that every flavor shares. Requires only the Tier and
- * UI contexts above it — both flavors provide those. Flavor-specific overlays
- * (e.g. the self-hosted account-link modal) are mounted by PortalProviders, not
- * here.
+ * The flavor-agnostic portal chrome: the shell (sidebar + search bar + routed
+ * view) plus the global overlays that every flavor shares. Requires only the
+ * Tier and UI contexts above it — both flavors provide those. Flavor-specific
+ * overlays (e.g. the self-hosted account-link modal) are mounted by
+ * PortalProviders, not here.
*/
export function PortalChrome() {
return (
- <>
-
+ // One app-config instance for every portal consumer (search gates, the
+ // settings modal) so they can't fetch twice or disagree.
+
{/* The pipeline builder reads the tool registry to list and configure operations. */}
-
- >
+
);
}
diff --git a/frontend/editor/src/portal/components/PortalSearchBar.css b/frontend/editor/src/portal/components/PortalSearchBar.css
new file mode 100644
index 0000000000..cf1d9971c7
--- /dev/null
+++ b/frontend/editor/src/portal/components/PortalSearchBar.css
@@ -0,0 +1,30 @@
+/* Slim strip at the top of the main column hosting the shared search bar.
+ Deliberately unpainted: only the input itself shows, on the page ground. */
+.portal-searchbar {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.22rem 1rem;
+ flex-shrink: 0;
+}
+
+.portal-searchbar .super-search {
+ flex: 0 1 24rem;
+ width: min(100%, 24rem);
+ max-width: 24rem;
+}
+
+.portal-searchbar .super-search input {
+ background-color: transparent;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ font-size: 12.5px;
+}
+
+/* The dropdown is portalled to , so it can't inherit portal styling by
+ nesting — this class (passed via dropdownClassName) is the portal's hook.
+ Colours come from the shared semantic tokens; only the roomier padding
+ differs from the editor dropdown. */
+.portal-search-dropdown.super-search-dropdown {
+ padding: 0.5rem;
+}
diff --git a/frontend/editor/src/portal/components/PortalSearchBar.tsx b/frontend/editor/src/portal/components/PortalSearchBar.tsx
new file mode 100644
index 0000000000..5913c43ecc
--- /dev/null
+++ b/frontend/editor/src/portal/components/PortalSearchBar.tsx
@@ -0,0 +1,29 @@
+import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
+import {
+ usePortalSearchResults,
+ usePortalSearchScopes,
+} from "@portal/hooks/usePortalSearchResults";
+import "@portal/components/PortalSearchBar.css";
+
+/**
+ * The portal face of the global super search — the same bar the editor's
+ * workbench shows, fed by the portal-wired results provider. Cmd/Ctrl+K
+ * focuses it (the bar registers its own shortcut). App config (the
+ * admin/login gates) comes from PortalChrome's shared provider. The distinct
+ * input id keeps this instance clear of the editor bar's stable id, which
+ * external focus helpers target.
+ */
+export function PortalSearchBar() {
+ const scopes = usePortalSearchScopes();
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/editor/src/portal/components/PortalSettingsHost.tsx b/frontend/editor/src/portal/components/PortalSettingsHost.tsx
index 954faeb848..0aec29dd14 100644
--- a/frontend/editor/src/portal/components/PortalSettingsHost.tsx
+++ b/frontend/editor/src/portal/components/PortalSettingsHost.tsx
@@ -13,22 +13,38 @@ import {
import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings";
import { useUI } from "@portal/contexts/UIContext";
+/**
+ * Settings sections the portal cannot host. Shared with the portal's search
+ * provider so these never surface as search results either.
+ *
+ * TODO: opening Keyboard Shortcuts in the portal white-screens the app (the
+ * section expects editor-only context). Hidden here as a stopgap; fix the
+ * section properly and drop this.
+ */
+export const PORTAL_HIDDEN_SECTION_KEYS: NavKey[] = ["hotkeys"];
+
/**
* Mounts the editor's settings modal (the app-wide settings surface) inside the
* portal. The portal deliberately lives outside the editor's AppProviders, so
- * this host supplies the contexts the settings tree needs: app config, user
- * preferences, the session provider the account sections read (flavor-resolved:
- * Spring on self-hosted, Supabase on SaaS — same underlying session the portal
- * is already signed in with), and the editor ThemeProvider (which also carries
- * the Mantine theme + toasts the sections expect). URL sync is off — the portal
- * owns its own route subtree, so the modal keeps its section purely in state.
+ * this host supplies the contexts the settings tree needs: user preferences,
+ * the session provider the account sections read (flavor-resolved: Spring on
+ * self-hosted, Supabase on SaaS — same underlying session the portal is
+ * already signed in with), and the editor ThemeProvider (which also carries
+ * the Mantine theme + toasts the sections expect). App config comes from
+ * PortalChrome's shared provider. URL sync is off — the portal owns its own
+ * route subtree, so the modal keeps its section purely in state.
*
* Everything (providers included) mounts on first open and stays mounted, so
* the editor theme wiring never runs for portal sessions that never open
* settings.
*/
export function PortalSettingsHost() {
- const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
+ const {
+ settingsOpen,
+ settingsInitialSection,
+ settingsInitialFocus,
+ closeSettings,
+ } = useUI();
const { t } = useTranslation();
const [everOpened, setEverOpened] = useState(false);
@@ -74,11 +90,9 @@ export function PortalSettingsHost() {
onClose={closeSettings}
urlSync={false}
initialSection={initialSection}
+ initialFocus={settingsInitialFocus}
extraSections={extraSections}
- // TODO: opening Keyboard Shortcuts in the portal white-screens
- // the app (the section expects editor-only context). Hidden here
- // as a stopgap; fix the section properly and drop this.
- hiddenSectionKeys={["hotkeys"]}
+ hiddenSectionKeys={PORTAL_HIDDEN_SECTION_KEYS}
/>
diff --git a/frontend/editor/src/portal/components/SearchModal.css b/frontend/editor/src/portal/components/SearchModal.css
deleted file mode 100644
index 501cfd140a..0000000000
--- a/frontend/editor/src/portal/components/SearchModal.css
+++ /dev/null
@@ -1,70 +0,0 @@
-.portal-search__input-row {
- display: flex;
- align-items: center;
- gap: 0.625rem;
- padding: 0.25rem 0.25rem 0.75rem;
- border-bottom: 1px solid var(--c-border-subtle);
- color: var(--c-text-subtle);
-}
-
-.portal-search__input {
- flex: 1 1 auto;
- font: inherit;
- font-size: 0.9375rem;
- background: transparent;
- border: none;
- outline: none;
- color: var(--c-text);
-}
-
-.portal-search__input::placeholder {
- color: var(--color-text-placeholder);
-}
-
-.portal-search__esc {
- font-family: var(--font-mono);
- font-size: 0.6875rem;
- padding: 0.0625rem 0.375rem;
- border-radius: var(--radius-xs);
- background: var(--c-surface-sunken);
- color: var(--c-text-subtle);
-}
-
-.portal-search__results {
- padding-top: 0.75rem;
- display: flex;
- flex-direction: column;
- gap: 1rem;
- max-height: 24rem;
- overflow-y: auto;
-}
-
-.portal-search__group-label {
- font-size: 0.6875rem;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--color-section-label);
- padding: 0 0.5rem 0.375rem;
-}
-
-.portal-search__item {
- width: 100%;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0.5rem 0.625rem;
- border-radius: var(--radius-sm);
- color: var(--c-text-muted);
- font-size: 0.8125rem;
- text-align: left;
-}
-
-.portal-search__item:hover {
- background: var(--c-hover);
-}
-
-.portal-search__item-hint {
- font-family: var(--font-mono);
- font-size: 0.6875rem;
- color: var(--c-text-subtle);
-}
diff --git a/frontend/editor/src/portal/components/SearchModal.stories.tsx b/frontend/editor/src/portal/components/SearchModal.stories.tsx
deleted file mode 100644
index 892c99c3ae..0000000000
--- a/frontend/editor/src/portal/components/SearchModal.stories.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { useEffect } from "react";
-import { http, HttpResponse } from "msw";
-import { SearchModal } from "@portal/components/SearchModal";
-import { useUI } from "@portal/contexts/UIContext";
-
-function ForceOpen() {
- const { openSearch } = useUI();
- useEffect(() => {
- openSearch();
- }, [openSearch]);
- return null;
-}
-
-const meta: Meta = {
- title: "Portal/Header/SearchModal",
- component: SearchModal,
- parameters: { layout: "fullscreen" },
- decorators: [
- (S) => (
-
-
-
-
- ),
- ],
-};
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {};
-
-export const EmptyCatalogue: Story = {
- parameters: {
- msw: {
- handlers: [
- http.get("/v1/search/quick-actions", () => HttpResponse.json([])),
- ],
- },
- },
-};
diff --git a/frontend/editor/src/portal/components/SearchModal.tsx b/frontend/editor/src/portal/components/SearchModal.tsx
deleted file mode 100644
index 6aac7e99d4..0000000000
--- a/frontend/editor/src/portal/components/SearchModal.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import { Button, EmptyState, Modal, Skeleton } from "@app/ui";
-import { useTranslation } from "react-i18next";
-import { useUI } from "@portal/contexts/UIContext";
-import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
-import { fetchQuickActions, type QuickAction } from "@portal/api/search";
-import { SearchIcon } from "@portal/components/icons";
-import "@portal/components/SearchModal.css";
-
-export function SearchModal() {
- const { t } = useTranslation();
- const { searchOpen, closeSearch } = useUI();
- const inputRef = useRef(null);
- const [query, setQuery] = useState("");
- const state = useAsync(() => fetchQuickActions(), []);
- const { data: actions } = state;
- const { isLoading } = useSectionFlags(state);
-
- useEffect(() => {
- if (searchOpen) {
- setQuery("");
- const t = setTimeout(() => inputRef.current?.focus(), 0);
- return () => clearTimeout(t);
- }
- return undefined;
- }, [searchOpen]);
-
- const filtered = useMemo(() => {
- if (!actions) return [] as QuickAction[];
- if (!query.trim()) return actions;
- const needle = query.trim().toLowerCase();
- return actions.filter((item) => item.label.toLowerCase().includes(needle));
- }, [actions, query]);
-
- const groups = useMemo(
- () =>
- filtered.reduce>((acc, item) => {
- if (!acc[item.group]) acc[item.group] = [];
- acc[item.group].push(item);
- return acc;
- }, {}),
- [filtered],
- );
-
- const groupKeys = Object.keys(groups);
- const isEmpty = !isLoading && groupKeys.length === 0;
-
- return (
-
-
-
-
- setQuery(e.target.value)}
- placeholder={t("portal.search.placeholder")}
- aria-label={t("portal.search.ariaLabel")}
- className="portal-search__input"
- autoComplete="off"
- spellCheck={false}
- />
-
- ESC
-
-
-
-
- {isLoading && (
-
-
-
-
-
-
- )}
- {isEmpty && (
-
- )}
- {!isLoading &&
- !isEmpty &&
- Object.entries(groups).map(([group, items]) => (
-
-
{group}
- {items.map((item) => (
-
- ))}
-
- ))}
-
-
-
- );
-}
diff --git a/frontend/editor/src/portal/components/docs/DocsNav.tsx b/frontend/editor/src/portal/components/docs/DocsNav.tsx
index 1063d5e98f..2f6258fca9 100644
--- a/frontend/editor/src/portal/components/docs/DocsNav.tsx
+++ b/frontend/editor/src/portal/components/docs/DocsNav.tsx
@@ -8,7 +8,8 @@ import type { DocsNavSection } from "@portal/api/docs";
* path ("functionality/security" is a child of "functionality"), so sub-sections
* nest under their parent. The root "Overview" section is static (always open, no
* toggle); every other section collapses, and only the branch leading to the
- * active doc opens by default. (Search lives in DocsSearch above this.)
+ * active doc opens by default. (Full-text docs search lives in the global
+ * super search.)
*/
// Matches the generator's ROOT_SECTION_ID: the intro section is never collapsible.
diff --git a/frontend/editor/src/portal/components/docs/DocsSearch.tsx b/frontend/editor/src/portal/components/docs/DocsSearch.tsx
deleted file mode 100644
index 49bdb3101f..0000000000
--- a/frontend/editor/src/portal/components/docs/DocsSearch.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { useTranslation } from "react-i18next";
-import { Button } from "@app/ui";
-import type { SearchResult, Segment } from "@portal/docs/search";
-
-/** Render highlighted segments, wrapping matched runs in . */
-function Highlighted({ segments }: { segments: Segment[] }) {
- return (
- <>
- {segments.map((s, i) =>
- s.hit ? (
-
- {s.text}
-
- ) : (
- {s.text}
- ),
- )}
- >
- );
-}
-
-/**
- * Docs search box + results. While a query is active it shows a ranked list of
- * matching docs — each with its section, a highlighted title, and a content
- * snippet — that navigates on click (or Enter). Arrow keys move the selection.
- */
-export function DocsSearch({
- query,
- onQueryChange,
- results,
- onSelect,
-}: {
- query: string;
- onQueryChange: (q: string) => void;
- results: SearchResult[];
- onSelect: (docId: string) => void;
-}) {
- const { t } = useTranslation();
- // -1 = nothing pre-selected; arrow keys drive this, the mouse uses CSS :hover.
- const [activeIndex, setActiveIndex] = useState(-1);
- const listRef = useRef(null);
- const hasQuery = query.trim().length > 0;
-
- useEffect(() => setActiveIndex(-1), [query]);
-
- useEffect(() => {
- listRef.current
- ?.querySelector('[data-active="true"]')
- ?.scrollIntoView?.({ block: "nearest" });
- }, [activeIndex]);
-
- const onKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Escape") {
- onQueryChange("");
- return;
- }
- if (!results.length) return;
- if (e.key === "ArrowDown") {
- e.preventDefault();
- setActiveIndex((i) => Math.min(i + 1, results.length - 1));
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- setActiveIndex((i) => Math.max(i - 1, 0));
- } else if (e.key === "Enter") {
- e.preventDefault();
- const hit = results[activeIndex >= 0 ? activeIndex : 0];
- if (hit) onSelect(hit.id);
- }
- };
-
- return (
-
-
-
- ⌕
-
- onQueryChange(e.target.value)}
- onKeyDown={onKeyDown}
- aria-label={t("portal.docs.search.placeholder")}
- />
-
-
- {hasQuery && (
-
- {results.length === 0 ? (
-
- {t("portal.docs.search.empty")}
-
- ) : (
- <>
-
- {t("portal.docs.search.results", { count: results.length })}
-
-
- {results.map((r, i) => (
- -
-
-
- ))}
-
- >
- )}
-
- )}
-
- );
-}
diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx
index ff4fb4b6f1..28e84691fb 100644
--- a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx
+++ b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx
@@ -23,6 +23,7 @@ const FULL_CAPS: UsersCapabilities = {
seats: false,
manageGrants: true,
removeScope: "org",
+ listingRequiresAdmin: true,
};
/** SaaS team-leader: invite / rename / remove-member only, no org group. */
@@ -44,6 +45,7 @@ const SAAS_CAPS: UsersCapabilities = {
seats: true,
manageGrants: false,
removeScope: "team",
+ listingRequiresAdmin: false,
};
/** A full org: one org owner and two teams, each with a leader. */
diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.tsx
index 2e78a6d4be..4367656d0b 100644
--- a/frontend/editor/src/portal/components/users/UsersDirectory.tsx
+++ b/frontend/editor/src/portal/components/users/UsersDirectory.tsx
@@ -215,7 +215,8 @@ export function UsersDirectory({
function renderRow(m: Member) {
const access = m.portalAccess ?? "none";
return (
-
+ // data-member-id lets deep links (?member=
) scroll to and flash a row.
+
diff --git a/frontend/editor/src/portal/contexts/UIContext.tsx b/frontend/editor/src/portal/contexts/UIContext.tsx
index ba0676ab6d..b5ebada257 100644
--- a/frontend/editor/src/portal/contexts/UIContext.tsx
+++ b/frontend/editor/src/portal/contexts/UIContext.tsx
@@ -7,11 +7,6 @@ import {
} from "react";
interface UIContextValue {
- searchOpen: boolean;
- openSearch: () => void;
- closeSearch: () => void;
- toggleSearch: () => void;
-
/** Off-canvas sidebar drawer on small screens (no-op chrome on desktop). */
mobileNavOpen: boolean;
openMobileNav: () => void;
@@ -32,7 +27,8 @@ interface UIContextValue {
* modal pick its own default. Cleared back to `null` on close.
*/
settingsInitialSection: string | null;
- openSettings: (section?: string) => void;
+ settingsInitialFocus: string | null;
+ openSettings: (section?: string, focus?: string) => void;
closeSettings: () => void;
/**
@@ -81,7 +77,6 @@ function writeSidebarCollapsed(collapsed: boolean): void {
}
export function UIProvider({ children }: { children: ReactNode }) {
- const [searchOpen, setSearchOpen] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] =
useState(readSidebarCollapsed);
@@ -90,6 +85,9 @@ export function UIProvider({ children }: { children: ReactNode }) {
const [settingsInitialSection, setSettingsInitialSection] = useState<
string | null
>(null);
+ const [settingsInitialFocus, setSettingsInitialFocus] = useState<
+ string | null
+ >(null);
const [linkModalOpen, setLinkModalOpen] = useState(false);
const [trialSetupRequested, setTrialSetupRequested] = useState(false);
const [linkModalMode, setLinkModalMode] = useState<"link" | "reauth">("link");
@@ -101,16 +99,8 @@ export function UIProvider({ children }: { children: ReactNode }) {
const value = useMemo
(
() => ({
- // Opening any overlay (search, settings, link modal) dismisses the mobile
- // nav drawer so overlays never stack on top of it.
- searchOpen,
- openSearch: () => {
- setMobileNavOpen(false);
- setSearchOpen(true);
- },
- closeSearch: () => setSearchOpen(false),
- toggleSearch: () => setSearchOpen((o) => !o),
-
+ // Opening any overlay (settings, link modal) dismisses the mobile nav
+ // drawer so overlays never stack on top of it.
mobileNavOpen,
openMobileNav: () => setMobileNavOpen(true),
closeMobileNav: () => setMobileNavOpen(false),
@@ -131,14 +121,17 @@ export function UIProvider({ children }: { children: ReactNode }) {
settingsOpen,
settingsInitialSection,
- openSettings: (section?: string) => {
+ settingsInitialFocus,
+ openSettings: (section?: string, focus?: string) => {
setMobileNavOpen(false);
setSettingsInitialSection(section ?? null);
+ setSettingsInitialFocus(focus ?? null);
setSettingsOpen(true);
},
closeSettings: () => {
setSettingsOpen(false);
setSettingsInitialSection(null);
+ setSettingsInitialFocus(null);
},
linkModalOpen,
@@ -152,6 +145,7 @@ export function UIProvider({ children }: { children: ReactNode }) {
setReopenSettingsAfterLink("account-link");
setSettingsOpen(false);
setSettingsInitialSection(null);
+ setSettingsInitialFocus(null);
}
setLinkModalOpen(true);
},
@@ -166,18 +160,19 @@ export function UIProvider({ children }: { children: ReactNode }) {
setLinkModalMode("link");
if (reopenSettingsAfterLink) {
setSettingsInitialSection(reopenSettingsAfterLink);
+ setSettingsInitialFocus(null);
setSettingsOpen(true);
setReopenSettingsAfterLink(null);
}
},
}),
[
- searchOpen,
mobileNavOpen,
sidebarCollapsed,
assistantOpen,
settingsOpen,
settingsInitialSection,
+ settingsInitialFocus,
linkModalOpen,
linkModalMode,
reopenSettingsAfterLink,
diff --git a/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts b/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts
new file mode 100644
index 0000000000..4be356bab0
--- /dev/null
+++ b/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts
@@ -0,0 +1,442 @@
+import { createElement, type ReactNode } from "react";
+import { renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (
+ key: string,
+ fallbackOrOptions?: string | Record,
+ ) => {
+ if (key === "portal.policies.defaultName") {
+ return `${(fallbackOrOptions as Record)?.category as string} Policy`;
+ }
+ if (typeof fallbackOrOptions === "string") return fallbackOrOptions;
+ const labels: Record = {
+ "portal.nav.users": "Users",
+ "portal.nav.policies": "Policies",
+ "portal.nav.pipelines": "Pipelines",
+ "portal.nav.sources": "Sources",
+ "portal.nav.editor": "Editor",
+ "superSearch.group.processor": "Processor",
+ "superSearch.group.settings": "Settings",
+ "superSearch.group.tools": "Tools",
+ "settings.email.smtpHost": "SMTP host",
+ "settings.email.title": "Email",
+ };
+ return labels[key] ?? key;
+ },
+ }),
+ };
+});
+
+vi.mock("react-router-dom", () => ({
+ useNavigate: vi.fn(() => vi.fn()),
+}));
+
+vi.mock("@app/contexts/AppConfigContext", () => ({
+ useAppConfig: vi.fn(() => ({
+ config: {
+ isAdmin: false,
+ enableLogin: true,
+ },
+ })),
+}));
+
+vi.mock("@app/contexts/ToolRegistryContext", () => ({
+ useToolRegistry: vi.fn(() => ({
+ allTools: {},
+ })),
+}));
+
+vi.mock("@portal/contexts/TierContext", () => ({
+ useTier: vi.fn(() => ({
+ tier: "pro",
+ })),
+}));
+
+const mockOpenSettings = vi.fn();
+vi.mock("@portal/contexts/UIContext", () => ({
+ useUI: vi.fn(() => ({
+ openSettings: mockOpenSettings,
+ })),
+}));
+
+vi.mock("@app/data/toolsTaxonomy", () => ({
+ getToolUrlPath: vi.fn((id: string) => `/tools/${id}`),
+ isComingSoonTool: vi.fn(() => false),
+}));
+
+vi.mock("@app/data/settingsSearchIndex", () => ({
+ SETTINGS_SEARCH_INDEX: [
+ {
+ section: "email",
+ anchor: "smtp-host",
+ labelKey: "settings.email.smtpHost",
+ labelFallback: "SMTP host",
+ keywords: ["smtp"],
+ },
+ ],
+}));
+
+vi.mock("@app/data/settingsSectionRegistry", () => ({
+ SETTINGS_SECTION_REGISTRY: [
+ {
+ key: "email",
+ labelKey: "settings.email.title",
+ labelFallback: "Email",
+ keywords: ["smtp", "email"],
+ requiresLogin: true,
+ },
+ ],
+}));
+
+vi.mock("@app/data/settingsContentSearch", () => ({
+ findSettingsContentMatch: vi.fn(() => null),
+ buildMatchSnippet: vi.fn(() => ""),
+}));
+
+vi.mock("@app/data/processorSearchIndex", () => ({
+ PROCESSOR_SEARCH_INDEX: [
+ {
+ id: "users",
+ labelKey: "portal.nav.users",
+ labelFallback: "Users",
+ path: "/portal/users",
+ keywords: ["members"],
+ },
+ {
+ id: "policies",
+ labelKey: "portal.nav.policies",
+ labelFallback: "Policies",
+ path: "/portal/policies",
+ keywords: ["rules"],
+ },
+ {
+ id: "pipelines",
+ labelKey: "portal.nav.pipelines",
+ labelFallback: "Pipelines",
+ path: "/portal/pipelines",
+ keywords: ["automation"],
+ },
+ {
+ id: "sources",
+ labelKey: "portal.nav.sources",
+ labelFallback: "Sources",
+ path: "/portal/sources",
+ keywords: ["connectors"],
+ },
+ {
+ id: "docs",
+ labelKey: "portal.nav.docs",
+ labelFallback: "Documentation",
+ path: "/portal/docs",
+ keywords: ["docs"],
+ },
+ ],
+ // Tests run as an org admin; per-scope access gating has its own coverage
+ // in the stubbed suite.
+ isPortalEntityScopeAccessible: () => true,
+}));
+
+// The roster is fetched through the flavor-resolved usersBackend (the same
+// path the shared users query uses), not @portal/api/users directly.
+vi.mock("@app/portal/usersBackend", () => ({
+ usersBackend: {
+ fetchUsers: vi.fn(),
+ },
+}));
+
+// Keep the real (pure) assemblePolicies; only the network fetchers are mocked.
+vi.mock("@portal/api/policies", async (importOriginal) => ({
+ ...(await importOriginal()),
+ fetchPoliciesList: vi.fn(),
+ fetchPolicyRuns: vi.fn(),
+}));
+
+vi.mock("@portal/api/pipelines", () => ({
+ fetchPipelines: vi.fn(),
+}));
+
+vi.mock("@portal/api/sources", () => ({
+ fetchSources: vi.fn(),
+}));
+
+import type { CatalogueEntry } from "@portal/api/policies";
+import { fetchPoliciesList, fetchPolicyRuns } from "@portal/api/policies";
+import type { PipelineView } from "@portal/api/pipelines";
+import { fetchPipelines } from "@portal/api/pipelines";
+import { fetchSources } from "@portal/api/sources";
+import type { Member, UsersResponse } from "@portal/api/users";
+import { usersBackend } from "@app/portal/usersBackend";
+import {
+ rankDocsResults,
+ rankPortalPipelineResults,
+ rankPortalPolicyResults,
+} from "@portal/search/entitySearch";
+import { usePortalSearchResults } from "@portal/hooks/usePortalSearchResults";
+
+function makePolicyEntry(overrides?: Partial): CatalogueEntry {
+ return {
+ category: {
+ id: "security",
+ label: "Security",
+ tone: "purple",
+ desc: "Protect sensitive documents",
+ },
+ config: {
+ summary: "",
+ rules: [],
+ scopeLabel: "",
+ fields: [],
+ defaultOperations: [],
+ },
+ policy: {
+ category: {
+ id: "security",
+ label: "Security",
+ tone: "purple",
+ desc: "Protect sensitive documents",
+ },
+ config: {
+ summary: "",
+ rules: [],
+ scopeLabel: "",
+ fields: [],
+ defaultOperations: [],
+ },
+ state: {
+ configured: true,
+ status: "active",
+ sources: [],
+ scopeTypes: [],
+ reviewerEmail: "",
+ fieldValues: {},
+ backendId: "policy-security",
+ },
+ steps: [],
+ stats: {
+ enforced: 0,
+ dataProcessed: "0 B",
+ activeFor: "0d",
+ },
+ activity: [],
+ },
+ ...overrides,
+ };
+}
+
+function makePipelineView(
+ id: string,
+ name: string,
+ trigger = "manual",
+): PipelineView {
+ return {
+ id,
+ name,
+ enabled: true,
+ status: "active",
+ trigger,
+ sources: [],
+ steps: [],
+ output: "inline",
+ owner: "alice",
+ };
+}
+
+function makeMember(overrides?: Partial): Member {
+ return {
+ id: "member-1",
+ name: "Alice Admin",
+ email: "alice@example.com",
+ role: "admin",
+ status: "active",
+ lastActive: "1m ago",
+ ...overrides,
+ };
+}
+
+function makeUsersResponse(members: Member[]): UsersResponse {
+ return {
+ summary: {
+ totalMembers: members.length,
+ pendingInvites: 0,
+ seatsUsed: members.length,
+ seatLimit: null,
+ },
+ members,
+ roles: [],
+ access: {
+ tier: "pro",
+ seatsUsed: members.length,
+ seatLimit: null,
+ },
+ mailEnabled: true,
+ emailInvitesEnabled: true,
+ };
+}
+
+function createDeferred() {
+ let resolve: ((value: T) => void) | undefined;
+ let reject: ((reason?: unknown) => void) | undefined;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return {
+ promise,
+ resolve: (value: T) => resolve?.(value),
+ reject: (reason?: unknown) => reject?.(reason),
+ };
+}
+
+function queryWrapper() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client }, children);
+}
+
+describe("usePortalSearchResults helpers", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockOpenSettings.mockReset();
+ vi.mocked(usersBackend.fetchUsers).mockResolvedValue(makeUsersResponse([]));
+ vi.mocked(fetchPoliciesList).mockResolvedValue([]);
+ vi.mocked(fetchPolicyRuns).mockResolvedValue([]);
+ vi.mocked(fetchPipelines).mockResolvedValue({ kpis: [], pipelines: [] });
+ vi.mocked(fetchSources).mockResolvedValue({ kpis: [], sources: [] });
+ });
+
+ it("ranks configured policies under the policies group", () => {
+ const openPolicy = vi.fn();
+ const results = rankPortalPolicyResults(
+ [makePolicyEntry()],
+ "security policy",
+ (key: string, options?: Record) =>
+ key === "portal.policies.defaultName"
+ ? `${options?.category as string} Policy`
+ : key,
+ openPolicy,
+ );
+
+ expect(results).toHaveLength(1);
+ expect(results[0]).toMatchObject({
+ key: "portal-policy:security",
+ group: "portal-policies",
+ title: "Security Policy",
+ });
+
+ void results[0]?.onSelect();
+ expect(openPolicy).toHaveBeenCalledWith("security");
+ });
+
+ it("filters policy-backed records out of the pipelines group", () => {
+ const openPipeline = vi.fn();
+ const results = rankPortalPipelineResults(
+ [
+ makePipelineView("policy-security", "Security Policy"),
+ makePipelineView("custom-pipeline", "Nightly OCR"),
+ ],
+ "nightly",
+ new Set(["policy-security"]),
+ openPipeline,
+ );
+
+ expect(results.map((result) => result.key)).toEqual([
+ "portal-pipeline:custom-pipeline",
+ ]);
+ });
+
+ it("full-text searches the bundled docs, not just their titles", () => {
+ const navigate = vi.fn();
+ // "Tesseract" appears in the OCR doc body but in no doc title — a hit
+ // whose snippet contains it proves content search.
+ const results = rankDocsResults("Tesseract", navigate);
+ expect(results.length).toBeGreaterThan(0);
+ expect(results[0]?.subtitle).toMatch(/tesseract/i);
+
+ void results[0]?.onSelect();
+ expect(navigate).toHaveBeenCalledWith(expect.stringMatching(/\/docs#./));
+ });
+
+ it("forwards portal settings row hits with their focus anchor", () => {
+ const { result } = renderHook(
+ () => usePortalSearchResults("smtp", true, { scopeIds: ["settings"] }),
+ { wrapper: queryWrapper() },
+ );
+
+ const settingHit = result.current.flatResults[0];
+ expect(settingHit?.key).toBe("setting:email:smtp-host");
+
+ void settingHit?.onSelect();
+ expect(mockOpenSettings).toHaveBeenCalledWith("email", "smtp-host");
+ expect(usersBackend.fetchUsers).not.toHaveBeenCalled();
+ });
+
+ it("fetches only the requested entity scope", async () => {
+ vi.mocked(usersBackend.fetchUsers).mockResolvedValue(
+ makeUsersResponse([makeMember({ id: "member-2", name: "Alice" })]),
+ );
+
+ const { result } = renderHook(
+ () =>
+ usePortalSearchResults("alice", true, { scopeIds: ["portal-users"] }),
+ { wrapper: queryWrapper() },
+ );
+
+ await waitFor(() =>
+ expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1),
+ );
+ await waitFor(() => expect(result.current.loadingFiles).toBe(false));
+
+ expect(fetchPoliciesList).not.toHaveBeenCalled();
+ expect(fetchPipelines).not.toHaveBeenCalled();
+ expect(fetchSources).not.toHaveBeenCalled();
+ expect(result.current.groups.map((group) => group.id)).toEqual([
+ "portal-users",
+ ]);
+ });
+
+ it("reuses the in-flight query after close/reopen instead of sticking in loading", async () => {
+ const firstUsers = createDeferred();
+ vi.mocked(usersBackend.fetchUsers).mockImplementationOnce(
+ () => firstUsers.promise,
+ );
+
+ const { result, rerender } = renderHook(
+ ({ query }) =>
+ usePortalSearchResults(query, true, { scopeIds: ["portal-users"] }),
+ {
+ initialProps: { query: "alice" },
+ wrapper: queryWrapper(),
+ },
+ );
+
+ await waitFor(() => expect(result.current.loadingFiles).toBe(true));
+ expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1);
+
+ rerender({ query: "" });
+ await waitFor(() => expect(result.current.loadingFiles).toBe(false));
+
+ rerender({ query: "alice" });
+ await waitFor(() => expect(result.current.loadingFiles).toBe(true));
+ expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1);
+
+ firstUsers.resolve(
+ makeUsersResponse([
+ makeMember({ id: "member-3", name: "Alice Reloaded" }),
+ ]),
+ );
+
+ await waitFor(() => expect(result.current.loadingFiles).toBe(false));
+ expect(result.current.groups.map((group) => group.id)).toEqual([
+ "portal-users",
+ ]);
+ });
+});
diff --git a/frontend/editor/src/portal/hooks/usePortalSearchResults.tsx b/frontend/editor/src/portal/hooks/usePortalSearchResults.tsx
new file mode 100644
index 0000000000..b839d980d6
--- /dev/null
+++ b/frontend/editor/src/portal/hooks/usePortalSearchResults.tsx
@@ -0,0 +1,321 @@
+import { useCallback, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import { getToolUrlPath } from "@app/data/toolsTaxonomy";
+import { isPortalEntityScopeAccessible } from "@app/data/processorSearchIndex";
+import { useAppConfig } from "@app/contexts/AppConfigContext";
+import { PORTAL_HIDDEN_SECTION_KEYS } from "@portal/components/PortalSettingsHost";
+import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
+import {
+ assembleSuperSearchGroups,
+ rankSettingsResults,
+ rankToolResults,
+ useSearchScopeFilter,
+} from "@app/hooks/useSuperSearch";
+import {
+ PORTAL_ENTITY_SCOPE_DEFS,
+ PORTAL_DOCS_SCOPE_ID,
+ type SuperSearchGates,
+ type SuperSearchGroup,
+ type SuperSearchGroupId,
+ type SuperSearchQueryOptions,
+ type SuperSearchScope,
+ type UseSuperSearchResult,
+} from "@app/types/superSearch";
+import type { ToolId } from "@app/types/toolId";
+import { assignLocation, openExternalUrl } from "@app/utils/safeNavigation";
+import { usersBackend } from "@app/portal/usersBackend";
+import {
+ assemblePolicies,
+ fetchPoliciesList,
+ fetchPolicyRuns,
+} from "@portal/api/policies";
+import { fetchPipelines } from "@portal/api/pipelines";
+import { fetchSources } from "@portal/api/sources";
+import { EDITOR_IS_SAME_APP, EDITOR_URL } from "@portal/auth/editorUrl";
+import { useTier } from "@portal/contexts/TierContext";
+import { useUI } from "@portal/contexts/UIContext";
+import { qk } from "@portal/queries/keys";
+import {
+ buildProcessorEntityGroups,
+ defaultPortalEntityScopes,
+ isDocsSearchable,
+ isVisiblePortalScope,
+ withPortalEntityDependencies,
+ type ProcessorEntities,
+} from "@portal/search/entitySearch";
+
+const EDITOR_GROUP_ORDER: SuperSearchGroupId[] = ["tools"];
+const SETTINGS_GROUP_ORDER: SuperSearchGroupId[] = ["settings"];
+const PROCESSOR_SECTION_LABEL_KEY = "superSearch.group.processor";
+const PROCESSOR_SECTION_LABEL_FALLBACK = "Processor";
+const SETTINGS_SECTION_LABEL_KEY = "superSearch.group.settings";
+const SETTINGS_SECTION_LABEL_FALLBACK = "Settings";
+const EDITOR_SECTION_LABEL_KEY = "portal.nav.editor";
+const EDITOR_SECTION_LABEL_FALLBACK = "Editor";
+
+/**
+ * Cross-origin editor URL for a tool. Only used when a separately-hosted
+ * editor is configured — the same-app case routes client-side instead: on
+ * bundled deploys the backend serves the frontend and 401s unauthenticated
+ * document GETs (the JWT lives in localStorage, so a full page load carries
+ * no credentials), which would bounce every tool hop to /login.
+ */
+function externalEditorHref(path: string): string {
+ return EDITOR_URL.replace(/\/$/, "") + path;
+}
+
+/**
+ * The portal bar's filter chips — every lane the editor offers except Files
+ * (files only open in the editor) and Pages (the sidebar covers navigation).
+ * Ordered to match the dropdown's section priority. Lanes whose data source
+ * refuses this session (the users roster for non-admins on self-hosted) get
+ * no chip — an offered lane must be able to return results.
+ */
+export function usePortalSearchScopes(): SuperSearchScope[] {
+ const { t } = useTranslation();
+ const { config } = useAppConfig();
+ const isAdmin = config?.isAdmin ?? false;
+
+ return useMemo(
+ () => [
+ ...PORTAL_ENTITY_SCOPE_DEFS.filter(
+ (def) =>
+ isVisiblePortalScope(def.id) &&
+ isPortalEntityScopeAccessible(def.id, isAdmin),
+ ).map((def) => ({
+ id: def.id,
+ label: t(def.labelKey, def.labelFallback),
+ aliases: [...def.aliases],
+ })),
+ ...(isDocsSearchable()
+ ? [
+ {
+ id: PORTAL_DOCS_SCOPE_ID,
+ label: t("superSearch.group.docs", "Docs"),
+ aliases: ["doc", "docs", "documentation"],
+ },
+ ]
+ : []),
+ {
+ id: "settings",
+ label: t("superSearch.group.settings", "Settings"),
+ aliases: ["setting", "settings"],
+ },
+ {
+ id: "tools",
+ label: t("superSearch.group.tools", "Tools"),
+ aliases: ["tool", "tools"],
+ },
+ ],
+ [t, isAdmin],
+ );
+}
+
+/**
+ * The portal's results provider for the shared super search bar: files stay
+ * editor-only, portal entity results are grouped under a Processor section,
+ * and the shared tools/settings lanes sit under an Editor section. Portal page
+ * routes themselves stay out of the portal search — once you're in the portal,
+ * the entities are the useful targets.
+ */
+export function usePortalSearchResults(
+ query: string,
+ active: boolean,
+ options?: SuperSearchQueryOptions,
+): UseSuperSearchResult {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { openSettings } = useUI();
+ const { allTools } = useToolRegistry();
+ const { config } = useAppConfig();
+ const { tier } = useTier();
+
+ const trimmed = query.trim();
+ const { scopeEnabled } = useSearchScopeFilter(options);
+ const requestedEntityScopes = useMemo(() => {
+ if (!active || trimmed.length === 0) return new Set();
+ const enabled = defaultPortalEntityScopes(config?.isAdmin ?? false).filter(
+ (scopeId) => scopeEnabled(scopeId),
+ );
+ return new Set(withPortalEntityDependencies(enabled));
+ }, [active, scopeEnabled, trimmed, config?.isAdmin]);
+
+ // Entity data rides the portal's shared query layer — the same keys the
+ // views use, so searching warms the view (and vice versa) and the client's
+ // staleTime/retry policy replaces bespoke fetch discipline. `enabled` keeps
+ // each lane's fetch behind its scope chip and the active-query gate.
+ const usersQuery = useQuery({
+ queryKey: qk.usersRoster(tier),
+ queryFn: () => usersBackend.fetchUsers(tier),
+ enabled: requestedEntityScopes.has("portal-users"),
+ });
+ const policiesListQuery = useQuery({
+ queryKey: qk.policiesList(),
+ queryFn: fetchPoliciesList,
+ enabled: requestedEntityScopes.has("portal-policies"),
+ });
+ const policyRunsQuery = useQuery({
+ queryKey: qk.policyRuns(),
+ queryFn: fetchPolicyRuns,
+ enabled: requestedEntityScopes.has("portal-policies"),
+ });
+ const pipelinesQuery = useQuery({
+ queryKey: qk.pipelines(),
+ queryFn: fetchPipelines,
+ enabled: requestedEntityScopes.has("portal-pipelines"),
+ });
+ const sourcesQuery = useQuery({
+ queryKey: qk.sources(),
+ queryFn: fetchSources,
+ enabled: requestedEntityScopes.has("portal-sources"),
+ });
+
+ // Loading only counts for lanes the current search actually requests — a
+ // fetch left in flight after its lane was deselected (or the bar closed)
+ // must not hold the dropdown's no-results gate open.
+ const loadingEntities =
+ (requestedEntityScopes.has("portal-users") && usersQuery.isLoading) ||
+ (requestedEntityScopes.has("portal-policies") &&
+ (policiesListQuery.isLoading || policyRunsQuery.isLoading)) ||
+ (requestedEntityScopes.has("portal-pipelines") &&
+ pipelinesQuery.isLoading) ||
+ (requestedEntityScopes.has("portal-sources") && sourcesQuery.isLoading);
+
+ const entities = useMemo(
+ () => ({
+ users: usersQuery.data?.members ?? [],
+ policies: policiesListQuery.data
+ ? assemblePolicies(policiesListQuery.data, policyRunsQuery.data ?? [])
+ .catalogue
+ : [],
+ pipelines: pipelinesQuery.data?.pipelines ?? [],
+ sources: sourcesQuery.data?.sources ?? [],
+ }),
+ [
+ usersQuery.data,
+ policiesListQuery.data,
+ policyRunsQuery.data,
+ pipelinesQuery.data,
+ sourcesQuery.data,
+ ],
+ );
+
+ const openTool = useCallback(
+ (id: ToolId) => {
+ // Link tools have no in-editor UI — navigating to a tool URL for one
+ // lands on a "tool not found" panel. Open their destination directly,
+ // matching how the editor's tool lists treat them.
+ const tool = allTools[id];
+ if (tool?.link) {
+ openExternalUrl(tool.link);
+ return;
+ }
+ const path = getToolUrlPath(id);
+ if (EDITOR_IS_SAME_APP) {
+ // One SPA: swap route-sets through the router. The portal tree
+ // unmounts and the editor mounts fresh at the tool URL, so its
+ // URL-driven tool init runs exactly as it does on a cold load.
+ navigate(path);
+ } else {
+ assignLocation(externalEditorHref(path));
+ }
+ },
+ [allTools, navigate],
+ );
+
+ const openSettingsSection = useCallback(
+ (section: string, anchor?: string) => openSettings(section, anchor),
+ [openSettings],
+ );
+
+ const gates = useMemo(
+ () =>
+ config
+ ? {
+ isAdmin: config.isAdmin ?? false,
+ loginEnabled: config.enableLogin ?? false,
+ showSettingsWhenNoLogin: config.showSettingsWhenNoLogin ?? true,
+ }
+ : null,
+ [config],
+ );
+
+ const entityGroups = useMemo(
+ () =>
+ buildProcessorEntityGroups(entities, trimmed, t, navigate, {
+ scopeEnabled,
+ }),
+ [entities, trimmed, t, navigate, scopeEnabled],
+ );
+
+ const groups = useMemo(() => {
+ // Section order: Processor first, Settings second, Editor last.
+ const settingsGroups = assembleSuperSearchGroups(
+ {
+ settings: scopeEnabled("settings")
+ ? rankSettingsResults(
+ trimmed,
+ t,
+ gates,
+ openSettingsSection,
+ undefined,
+ PORTAL_HIDDEN_SECTION_KEYS,
+ )
+ : [],
+ },
+ t,
+ SETTINGS_GROUP_ORDER,
+ ).map((group) => ({
+ ...group,
+ sectionLabel: t(
+ SETTINGS_SECTION_LABEL_KEY,
+ SETTINGS_SECTION_LABEL_FALLBACK,
+ ),
+ }));
+
+ const editorGroups = assembleSuperSearchGroups(
+ {
+ tools: scopeEnabled("tools")
+ ? rankToolResults(allTools, trimmed, openTool)
+ : [],
+ },
+ t,
+ EDITOR_GROUP_ORDER,
+ ).map((group) => ({
+ ...group,
+ sectionLabel: t(EDITOR_SECTION_LABEL_KEY, EDITOR_SECTION_LABEL_FALLBACK),
+ }));
+
+ return [
+ ...entityGroups.map((group) => ({
+ ...group,
+ sectionLabel: t(
+ PROCESSOR_SECTION_LABEL_KEY,
+ PROCESSOR_SECTION_LABEL_FALLBACK,
+ ),
+ })),
+ ...settingsGroups,
+ ...editorGroups,
+ ];
+ }, [
+ entityGroups,
+ gates,
+ openSettingsSection,
+ openTool,
+ scopeEnabled,
+ allTools,
+ t,
+ trimmed,
+ ]);
+
+ const flatResults = useMemo(
+ () => groups.flatMap((group) => group.results),
+ [groups],
+ );
+
+ // loadingFiles doubles as "an async source is still loading" for the
+ // dropdown's no-results gate — here that's the entity fetch.
+ return { groups, flatResults, loadingFiles: loadingEntities };
+}
diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts
index d5428b1853..13b22d6941 100644
--- a/frontend/editor/src/portal/mocks/handlers/index.ts
+++ b/frontend/editor/src/portal/mocks/handlers/index.ts
@@ -1,7 +1,6 @@
import { assistantHandlers } from "@portal/mocks/handlers/assistant";
import { authHandlers } from "@portal/mocks/handlers/auth";
import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
-import { searchHandlers } from "@portal/mocks/handlers/search";
import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
@@ -21,7 +20,6 @@ export const handlers = [
...authHandlers,
...notificationsHandlers,
...assistantHandlers,
- ...searchHandlers,
...pipelinesHandlers,
...sourcesHandlers,
...infrastructureHandlers,
diff --git a/frontend/editor/src/portal/mocks/handlers/search.ts b/frontend/editor/src/portal/mocks/handlers/search.ts
deleted file mode 100644
index b62e8bc910..0000000000
--- a/frontend/editor/src/portal/mocks/handlers/search.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { http, HttpResponse } from "msw";
-import { QUICK_ACTIONS } from "@portal/mocks/search";
-
-export const searchHandlers = [
- http.get("/v1/search/quick-actions", () => {
- return HttpResponse.json(QUICK_ACTIONS);
- }),
-];
diff --git a/frontend/editor/src/portal/mocks/search.ts b/frontend/editor/src/portal/mocks/search.ts
deleted file mode 100644
index 86d332de70..0000000000
--- a/frontend/editor/src/portal/mocks/search.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Mock quick-action catalogue for the ⌘K search palette. The QuickAction type
- * lives in api/search.ts (the backend contract); this module only builds fake
- * data for Storybook and tests.
- */
-
-import type { QuickAction } from "@portal/api/search";
-
-export const QUICK_ACTIONS: QuickAction[] = [
- { group: "Jump to", label: "Home", hint: "G H" },
- { group: "Jump to", label: "Pipelines", hint: "G P" },
- { group: "Jump to", label: "Sources", hint: "G S" },
- { group: "Jump to", label: "Documents", hint: "G D" },
- { group: "Create", label: "New pipeline", hint: "N P" },
- { group: "Create", label: "New API key", hint: "N K" },
- { group: "Theme", label: "Toggle dark / light", hint: "T" },
-];
diff --git a/frontend/editor/src/portal/search/entitySearch.tsx b/frontend/editor/src/portal/search/entitySearch.tsx
new file mode 100644
index 0000000000..1d0a7cc489
--- /dev/null
+++ b/frontend/editor/src/portal/search/entitySearch.tsx
@@ -0,0 +1,393 @@
+import {
+ PROCESSOR_SEARCH_INDEX,
+ isPortalEntityScopeAccessible,
+} from "@app/data/processorSearchIndex";
+import {
+ PORTAL_ENTITY_SCOPE_DEFS,
+ PORTAL_DOCS_SCOPE_ID,
+ type SuperSearchGroup,
+ type SuperSearchResult,
+} from "@app/types/superSearch";
+import { rankByFuzzy } from "@app/utils/fuzzySearch";
+import {
+ assemblePolicies,
+ fetchPoliciesList,
+ fetchPolicyRuns,
+ type CatalogueEntry,
+} from "@portal/api/policies";
+import { fetchPipelines, type PipelineView } from "@portal/api/pipelines";
+import { fetchSources, type SourceView } from "@portal/api/sources";
+import type { Member } from "@portal/api/users";
+// Flavor-resolved users backend: self-hosted reads the proprietary admin
+// endpoints, SaaS the invitation-based team endpoints (the admin ones 403
+// there for the always-ROLE_USER sessions).
+import { usersBackend } from "@app/portal/usersBackend";
+import {
+ DocsIcon,
+ PipelinesIcon,
+ PoliciesIcon,
+ SourcesIcon,
+ UsersIcon,
+} from "@portal/components/icons";
+import type { Tier } from "@portal/contexts/TierContext";
+import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
+import { allDocs, loadDocsNav } from "@portal/docs/manifest/registry";
+import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search";
+
+/**
+ * The Processor's entity search: users, policies, pipelines and sources,
+ * fetched per scope and fuzzy-ranked client-side. Shared by both super search
+ * hosts — the portal bar imports it statically, the editor bar loads it on
+ * demand through the processorEntitySearch seam (a static import there would
+ * pull the portal into the main bundle).
+ */
+
+export const PORTAL_ENTITY_SCOPE_IDS = PORTAL_ENTITY_SCOPE_DEFS.map(
+ (def) => def.id,
+);
+
+export type PortalEntityScopeId =
+ (typeof PORTAL_ENTITY_SCOPE_DEFS)[number]["id"];
+
+export type PortalEntityItems =
+ | Member[]
+ | CatalogueEntry[]
+ | PipelineView[]
+ | SourceView[];
+
+export interface ProcessorEntities {
+ users: Member[];
+ policies: CatalogueEntry[];
+ pipelines: PipelineView[];
+ sources: SourceView[];
+}
+
+/**
+ * How many results each entity ranker computes — the ceiling the dropdown's
+ * "show more" can reveal (the component shows a small initial slice).
+ */
+export const ENTITY_GROUP_LIMIT = 24;
+
+/** How long a fetched entity scope stays fresh before a search refetches it. */
+export const ENTITY_REFRESH_MS = 30_000;
+
+const PORTAL_VIEW_BY_SCOPE_ID = Object.fromEntries(
+ PORTAL_ENTITY_SCOPE_DEFS.map((def) => [def.id, def.viewId]),
+) as Record;
+
+const VISIBLE_PORTAL_VIEW_IDS = new Set(
+ PROCESSOR_SEARCH_INDEX.map((entry) => entry.id),
+);
+
+/** Whether the flavor's portal nav ships the view an entity scope targets. */
+export function isVisiblePortalScope(scopeId: PortalEntityScopeId): boolean {
+ return VISIBLE_PORTAL_VIEW_IDS.has(PORTAL_VIEW_BY_SCOPE_ID[scopeId]);
+}
+
+/** Whether this build ships the in-app developer docs (so they're searchable). */
+export function isDocsSearchable(): boolean {
+ return VISIBLE_PORTAL_VIEW_IDS.has("docs");
+}
+
+// The docs manifest is static (bundled JSON), so the full-text index — the
+// plaintext strip over every doc — is built once and reused across queries.
+let docsSearchIndex: SearchDoc[] | null = null;
+function getDocsSearchIndex(): SearchDoc[] {
+ if (!docsSearchIndex) {
+ const sectionLabels = new Map(loadDocsNav().map((s) => [s.id, s.label]));
+ docsSearchIndex = allDocs().map((doc) => ({
+ id: doc.id,
+ title: doc.title,
+ sectionLabel: sectionLabels.get(doc.section) ?? "",
+ text: toPlainText(doc.markdown),
+ }));
+ }
+ return docsSearchIndex;
+}
+
+export function rankDocsResults(
+ trimmed: string,
+ navigate: (path: string) => void,
+ limit = ENTITY_GROUP_LIMIT,
+): SuperSearchResult[] {
+ if (!isDocsSearchable()) return [];
+ return searchDocs(getDocsSearchIndex(), trimmed, limit).map((result) => ({
+ key: `portal-doc:${result.id}`,
+ group: PORTAL_DOCS_SCOPE_ID,
+ title: result.title,
+ // The matched-content snippet (full text is what makes docs worth
+ // searching), falling back to the doc's section when the hit is title-only.
+ subtitle:
+ result.snippet
+ .map((seg) => seg.text)
+ .join("")
+ .trim() || result.sectionLabel,
+ icon: ,
+ score: result.score,
+ onSelect: () => navigate(`${toPortalPath(VIEW_PATHS.docs)}#${result.id}`),
+ }));
+}
+
+export function withPortalEntityDependencies(
+ scopes: readonly PortalEntityScopeId[],
+): readonly PortalEntityScopeId[] {
+ // Pipeline rows must exclude policy-backed records, so they depend on the
+ // policy catalogue even when the user only scoped into pipelines.
+ if (
+ !scopes.includes("portal-pipelines") ||
+ scopes.includes("portal-policies")
+ ) {
+ return scopes;
+ }
+ return [...scopes, "portal-policies"];
+}
+
+/** Every entity scope the flavor ships AND the session can actually query
+ * (see isPortalEntityScopeAccessible), dependencies included — the request
+ * set for an unscoped search. */
+export function defaultPortalEntityScopes(
+ isAdmin: boolean,
+): readonly PortalEntityScopeId[] {
+ return withPortalEntityDependencies(
+ PORTAL_ENTITY_SCOPE_IDS.filter(
+ (scopeId) =>
+ isVisiblePortalScope(scopeId) &&
+ isPortalEntityScopeAccessible(scopeId, isAdmin),
+ ),
+ );
+}
+
+/** One entity scope's fetch, for the editor seam (which has no QueryClient —
+ * the portal bar reads the shared query layer instead). `tier` shapes only
+ * presentational fields on the users payload, never the lists — hosts without
+ * a TierContext pass "free". */
+export async function fetchPortalEntityScope(
+ scopeId: PortalEntityScopeId,
+ tier: Tier,
+): Promise {
+ switch (scopeId) {
+ case "portal-users":
+ return (await usersBackend.fetchUsers(tier)).members;
+ case "portal-policies": {
+ const [list, runs] = await Promise.all([
+ fetchPoliciesList(),
+ fetchPolicyRuns(),
+ ]);
+ return assemblePolicies(list, runs).catalogue;
+ }
+ case "portal-pipelines":
+ return (await fetchPipelines()).pipelines;
+ case "portal-sources":
+ return (await fetchSources()).sources;
+ }
+}
+
+/** Assembles per-scope cache values into the typed entity sets. The casts are
+ * sound because fetchPortalEntityScope keys each item type to its scope. */
+export function toProcessorEntities(
+ values: Partial>,
+): ProcessorEntities {
+ return {
+ users: (values["portal-users"] as Member[] | undefined) ?? [],
+ policies: (values["portal-policies"] as CatalogueEntry[] | undefined) ?? [],
+ pipelines: (values["portal-pipelines"] as PipelineView[] | undefined) ?? [],
+ sources: (values["portal-sources"] as SourceView[] | undefined) ?? [],
+ };
+}
+
+type Translate = (key: string, options?: Record) => string;
+
+function policyResultTitle(entry: CatalogueEntry, t: Translate) {
+ const category = t(entry.category.label);
+ return entry.policy
+ ? t("portal.policies.defaultName", { category })
+ : category;
+}
+
+export function rankPortalPolicyResults(
+ entries: CatalogueEntry[],
+ trimmed: string,
+ t: Translate,
+ openPolicy: (categoryId: string) => void,
+ limit = ENTITY_GROUP_LIMIT,
+): SuperSearchResult[] {
+ return rankByFuzzy(
+ entries.filter((entry) => !entry.category.comingSoon),
+ trimmed,
+ [
+ (entry) => policyResultTitle(entry, t),
+ (entry) => t(entry.category.label),
+ (entry) => t(entry.category.desc),
+ ],
+ )
+ .slice(0, limit)
+ .map(({ item, score }) => ({
+ key: `portal-policy:${item.category.id}`,
+ group: "portal-policies",
+ title: policyResultTitle(item, t),
+ subtitle: t(item.category.desc),
+ icon: ,
+ score,
+ onSelect: () => openPolicy(item.category.id),
+ }));
+}
+
+export function rankPortalPipelineResults(
+ entries: PipelineView[],
+ trimmed: string,
+ excludedIds: ReadonlySet,
+ openPipeline: (pipelineId: string) => void,
+ limit = ENTITY_GROUP_LIMIT,
+): SuperSearchResult[] {
+ return rankByFuzzy(
+ entries.filter((entry) => !excludedIds.has(entry.id)),
+ trimmed,
+ [(entry) => entry.name, (entry) => entry.trigger],
+ )
+ .slice(0, limit)
+ .map(({ item, score }) => ({
+ key: `portal-pipeline:${item.id}`,
+ group: "portal-pipelines",
+ title: item.name,
+ subtitle: item.trigger,
+ icon: ,
+ score,
+ onSelect: () => openPipeline(item.id),
+ }));
+}
+
+export interface BuildEntityGroupsOptions {
+ /** Host scope filter; defaults to every scope enabled. */
+ scopeEnabled?: (scopeId: string) => boolean;
+}
+
+/**
+ * Ranks the entity sets into display groups. Selects navigate to the entity's
+ * portal route (deep links where the views support them) — the portal is a
+ * route-set of the same SPA, so this works from either app.
+ */
+export function buildProcessorEntityGroups(
+ entities: ProcessorEntities,
+ trimmed: string,
+ t: Translate,
+ navigate: (path: string) => void,
+ options: BuildEntityGroupsOptions = {},
+): SuperSearchGroup[] {
+ if (!trimmed) return [];
+ const scopeEnabled = options.scopeEnabled ?? (() => true);
+ const groups: SuperSearchGroup[] = [];
+
+ const includeScope = (scopeId: PortalEntityScopeId) =>
+ isVisiblePortalScope(scopeId) && scopeEnabled(scopeId);
+
+ const users = includeScope("portal-users")
+ ? rankByFuzzy(entities.users, trimmed, [
+ (member) => member.name,
+ (member) => member.email,
+ ])
+ .slice(0, ENTITY_GROUP_LIMIT)
+ .map(({ item, score }) => ({
+ key: `portal-user:${item.id}`,
+ group: "portal-users",
+ title: item.name,
+ subtitle: item.email,
+ icon: ,
+ score,
+ onSelect: () =>
+ navigate(
+ `${toPortalPath(VIEW_PATHS.users)}?member=${encodeURIComponent(item.id)}`,
+ ),
+ }))
+ : [];
+ if (users.length > 0) {
+ groups.push({
+ id: "portal-users",
+ label: t("portal.nav.users"),
+ results: users,
+ });
+ }
+
+ const policies = includeScope("portal-policies")
+ ? rankPortalPolicyResults(
+ entities.policies,
+ trimmed,
+ t,
+ (categoryId) =>
+ navigate(
+ `${toPortalPath(VIEW_PATHS.policies)}?category=${encodeURIComponent(categoryId)}`,
+ ),
+ ENTITY_GROUP_LIMIT,
+ )
+ : [];
+ if (policies.length > 0) {
+ groups.push({
+ id: "portal-policies",
+ label: t("portal.nav.policies"),
+ results: policies,
+ });
+ }
+
+ // Policy-backed pipelines already surface as policies; listing them twice
+ // under different names would read as duplicates.
+ const policyPipelineIds = new Set(
+ entities.policies.flatMap((entry) =>
+ entry.policy?.state.backendId ? [entry.policy.state.backendId] : [],
+ ),
+ );
+ const pipelines = includeScope("portal-pipelines")
+ ? rankPortalPipelineResults(
+ entities.pipelines,
+ trimmed,
+ policyPipelineIds,
+ (pipelineId) =>
+ navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipelineId}`),
+ ENTITY_GROUP_LIMIT,
+ )
+ : [];
+ if (pipelines.length > 0) {
+ groups.push({
+ id: "portal-pipelines",
+ label: t("portal.nav.pipelines"),
+ results: pipelines,
+ });
+ }
+
+ const sources = includeScope("portal-sources")
+ ? rankByFuzzy(entities.sources, trimmed, [
+ (source) => source.name,
+ (source) => source.type,
+ ])
+ .slice(0, ENTITY_GROUP_LIMIT)
+ .map(({ item, score }) => ({
+ key: `portal-source:${item.id}`,
+ group: "portal-sources",
+ title: item.name,
+ subtitle: item.type,
+ icon: ,
+ score,
+ onSelect: () =>
+ navigate(`${toPortalPath(VIEW_PATHS.sources)}/${item.id}`),
+ }))
+ : [];
+ if (sources.length > 0) {
+ groups.push({
+ id: "portal-sources",
+ label: t("portal.nav.sources"),
+ results: sources,
+ });
+ }
+
+ const docs =
+ isDocsSearchable() && scopeEnabled(PORTAL_DOCS_SCOPE_ID)
+ ? rankDocsResults(trimmed, navigate, ENTITY_GROUP_LIMIT)
+ : [];
+ if (docs.length > 0) {
+ groups.push({
+ id: PORTAL_DOCS_SCOPE_ID,
+ label: t("superSearch.group.docs"),
+ results: docs,
+ });
+ }
+
+ return groups;
+}
diff --git a/frontend/editor/src/portal/views/DeveloperDocs.css b/frontend/editor/src/portal/views/DeveloperDocs.css
index 314cf17eef..6487a9afff 100644
--- a/frontend/editor/src/portal/views/DeveloperDocs.css
+++ b/frontend/editor/src/portal/views/DeveloperDocs.css
@@ -45,137 +45,6 @@
gap: 0.25rem;
}
-/* Search */
-.portal-docs__search {
- margin-bottom: 0.75rem;
- padding: 0 0.75rem;
-}
-
-.portal-docs__search-box {
- position: relative;
-}
-
-.portal-docs__search-icon {
- position: absolute;
- left: 0.625rem;
- top: 50%;
- transform: translateY(-50%);
- font-size: 0.9375rem;
- color: var(--c-text-subtle);
- pointer-events: none;
-}
-
-.portal-docs__search-input {
- width: 100%;
- padding: 0.4rem 0.6rem 0.4rem 1.9rem;
- font-size: 0.8125rem;
- color: var(--c-text);
- background: var(--color-bg-subtle);
- border: 1px solid var(--c-border-subtle);
- border-radius: var(--radius-md);
- outline: none;
-}
-
-.portal-docs__search-input:focus {
- border-color: var(--c-primary);
-}
-
-.portal-docs__nav-empty {
- font-size: 0.8125rem;
- color: var(--c-text-subtle);
- padding: 0.5rem 0.75rem;
- margin: 0;
-}
-
-/* ── Search results ────────────────────────────────────────────────────── */
-
-.portal-docs__results {
- margin-top: 0.5rem;
-}
-
-.portal-docs__results-count {
- font-size: 0.6875rem;
- font-weight: 500;
- color: var(--c-text-subtle);
- padding: 0 0.75rem 0.5rem;
-}
-
-.portal-docs__results-list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
-}
-
-/* Hairline divider between results for clear, calm separation. */
-.portal-docs__results-list li + li {
- border-top: 1px solid var(--c-border-subtle);
-}
-
-.portal-docs__result {
- height: auto;
- padding: 0.5rem 0.75rem;
- border-radius: 0;
-}
-
-.portal-docs__result:hover,
-.portal-docs__result.is-active {
- background: var(--c-hover);
-}
-
-.portal-docs__result-body {
- display: flex;
- flex-direction: column;
- gap: 0.125rem;
- width: 100%;
- min-width: 0;
- text-align: left;
- white-space: normal;
-}
-
-/* Title + section share one line; the title truncates before the section. */
-.portal-docs__result-head {
- display: flex;
- align-items: baseline;
- gap: 0.4rem;
- min-width: 0;
-}
-
-.portal-docs__result-title {
- font-size: 0.8125rem;
- font-weight: 600;
- color: var(--c-text);
- line-height: 1.3;
- min-width: 0;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-
-.portal-docs__result-section {
- flex-shrink: 0;
- font-size: 0.6875rem;
- color: var(--c-text-subtle);
-}
-
-.portal-docs__result-snippet {
- font-size: 0.75rem;
- line-height: 1.4;
- color: var(--c-text-subtle);
- display: -webkit-box;
- -webkit-line-clamp: 1;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-/* Subtle match emphasis — coloured text, not a filled block. */
-.portal-docs__hl {
- color: var(--c-accent-text);
- font-weight: 600;
- background: none;
-}
-
.portal-docs__nav-group {
display: flex;
flex-direction: column;
diff --git a/frontend/editor/src/portal/views/DeveloperDocs.test.tsx b/frontend/editor/src/portal/views/DeveloperDocs.test.tsx
index d2071011d8..354c6359c6 100644
--- a/frontend/editor/src/portal/views/DeveloperDocs.test.tsx
+++ b/frontend/editor/src/portal/views/DeveloperDocs.test.tsx
@@ -25,7 +25,6 @@ const renderDocs = (ui: ReactElement) =>
describe("DeveloperDocs — markdown browser over the generated manifest", () => {
it("keeps Overview static (open, no toggle) and other sections collapsed", () => {
renderDocs();
- expect(screen.getByRole("searchbox")).toBeInTheDocument();
// Overview is static: its items show, and it has no toggle button.
expect(
screen.getByRole("button", { name: "Production Deployment Guide" }),
@@ -50,23 +49,6 @@ describe("DeveloperDocs — markdown browser over the generated manifest", () =>
).toBeInTheDocument();
});
- it("searches doc content (not just titles), shows a snippet, and navigates", async () => {
- renderDocs();
- // "Tesseract" appears in the OCR doc body but in no doc title — a result
- // whose snippet contains it proves full-text (content) search.
- fireEvent.change(screen.getByRole("searchbox"), {
- target: { value: "Tesseract" },
- });
- const hits = await screen.findAllByRole("button", { name: /Tesseract/i });
- expect(hits.length).toBeGreaterThan(0);
- fireEvent.click(hits[0]);
- await waitFor(() =>
- expect(
- screen.queryByText(/locally hosted web application/i),
- ).not.toBeInTheDocument(),
- );
- });
-
it("follows an internal doc: link inside the rendered markdown", async () => {
renderDocs();
// The Getting Started body links to the Migration guide via the doc: scheme.
diff --git a/frontend/editor/src/portal/views/DeveloperDocs.tsx b/frontend/editor/src/portal/views/DeveloperDocs.tsx
index fb62b8566c..d32666e1dd 100644
--- a/frontend/editor/src/portal/views/DeveloperDocs.tsx
+++ b/frontend/editor/src/portal/views/DeveloperDocs.tsx
@@ -3,25 +3,22 @@ import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { Button, EmptyState } from "@app/ui";
import { DocsNav } from "@portal/components/docs/DocsNav";
-import { DocsSearch } from "@portal/components/docs/DocsSearch";
import { DocsSection } from "@portal/components/docs/DocsSection";
import { DocsToc } from "@portal/components/docs/DocsToc";
import { MarkdownDoc } from "@portal/components/docs/MarkdownDoc";
import { extractHeadings } from "@portal/docs/headings";
import {
- allDocs,
firstDocId,
loadDoc,
loadDocsNav,
} from "@portal/docs/manifest/registry";
-import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search";
import "@portal/views/DeveloperDocs.css";
/**
* Developer Docs — a markdown browser over the docs manifest generated from the
* Stirling docs repo (see scripts/sync-portal-docs.mts). The nav is auto-sorted
- * from the repo's folders + frontmatter; content is the repo markdown, and the
- * search box does full-text search across every doc.
+ * from the repo's folders + frontmatter; content is the repo markdown. Full-text
+ * search across docs lives in the global super search (Cmd/Ctrl+K).
*/
export function DeveloperDocs() {
const { t } = useTranslation();
@@ -29,24 +26,10 @@ export function DeveloperDocs() {
const navigate = useNavigate();
const contentRef = useRef(null);
const [navOpen, setNavOpen] = useState(false);
- const [query, setQuery] = useState("");
const nav = useMemo(() => loadDocsNav(), []);
const fallback = useMemo(() => firstDocId(), []);
- // Full-text index over every doc's plaintext body (built once).
- const index = useMemo(() => {
- const labels = new Map(nav.map((s) => [s.id, s.label]));
- return allDocs().map((d) => ({
- id: d.id,
- title: d.title,
- sectionLabel: labels.get(d.section) ?? "",
- text: toPlainText(d.markdown),
- }));
- }, [nav]);
- const results = useMemo(() => searchDocs(index, query), [index, query]);
- const searching = query.trim().length > 0;
-
// Deep-link support: the active doc id lives in the URL hash.
const hashId = decodeURIComponent(hash.replace(/^#/, ""));
const activeId = hashId && loadDoc(hashId) ? hashId : fallback;
@@ -61,12 +44,11 @@ export function DeveloperDocs() {
[doc],
);
- // Navigating closes the mobile drawer, clears the search, and resets the pane.
+ // Navigating closes the mobile drawer and resets the pane.
const onSelect = useCallback(
(id: string) => {
navigate({ hash: id });
setNavOpen(false);
- setQuery("");
},
[navigate],
);
@@ -100,19 +82,11 @@ export function DeveloperDocs() {
{t("portal.docs.browse")}
- {/* Layout columns, not landmarks: the search and the two