From d734867ea9ee1f55ed8bbfb64add5aa4a7057f84 Mon Sep 17 00:00:00 2001 From: Reece Date: Wed, 15 Jul 2026 17:06:06 +0100 Subject: [PATCH] feat(search): rank the portal bar over the same sources as the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The portal's provider now composes the shared source builders extracted from useSuperSearch — tools, settings and Processor pages rank identically in both apps, with Processor leading in the portal and the editor keeping its own order. Files stay editor-only (a file can only open there). Selecting a tool from the portal hands over to the editor via its URL routing; settings opens the portal's settings modal. Opening a file from the editor's search now loads its stored stub instead of re-adding it, which used to persist a duplicate record. --- .../public/locales/en-GB/translation.toml | 2 - .../public/locales/en-US/translation.toml | 3 - .../editor/src/core/hooks/useSuperSearch.ts | 421 +++++++++++------- .../src/portal/components/PortalSearchBar.tsx | 15 +- .../portal/hooks/usePortalSearchResults.ts | 144 ++++-- 5 files changed, 360 insertions(+), 225 deletions(-) diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 34e7421617..3433952d12 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -7895,8 +7895,6 @@ viewAll = "View all" description = "Pipeline runs, deploys and agent events will appear here." title = "Nothing here yet" -[portal.search] -goTo = "Go to" [portal.settings.groups] admin = "Admin" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 720ced27eb..d389c92d12 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7985,9 +7985,6 @@ viewAll = "View all" description = "Pipeline runs, deploys and agent events will appear here." title = "Nothing here yet" -[portal.search] -goTo = "Go to" - [portal.settings.groups] admin = "Admin" diff --git a/frontend/editor/src/core/hooks/useSuperSearch.ts b/frontend/editor/src/core/hooks/useSuperSearch.ts index bafaa6f1b0..a3245572bf 100644 --- a/frontend/editor/src/core/hooks/useSuperSearch.ts +++ b/frontend/editor/src/core/hooks/useSuperSearch.ts @@ -7,6 +7,7 @@ import { useState, } from "react"; import type React from "react"; +import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -14,14 +15,18 @@ 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 { useFileHandler } from "@app/hooks/useFileHandler"; +import { useFileActions } from "@app/contexts/file/fileHooks"; import { fileStorage } from "@app/services/fileStorage"; import { rankByFuzzy, idToWords } from "@app/utils/fuzzySearch"; import type { StirlingFileStub } from "@app/types/fileContext"; import type { ToolId } from "@app/types/toolId"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex"; import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry"; -import { PROCESSOR_SEARCH_INDEX } from "@app/data/processorSearchIndex"; +import { + PROCESSOR_SEARCH_INDEX, + type ProcessorSearchEntry, +} from "@app/data/processorSearchIndex"; export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor"; @@ -64,35 +69,23 @@ export interface UseSuperSearchResult { loadingFiles: boolean; } -/** - * Aggregates the three super-search providers — My Files, Tools, and Settings — - * into a single ranked, grouped result set, and wires each result's select - * action (open file → viewer, select tool, deep-link into settings). - * - * @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, -): UseSuperSearchResult { - const { t } = useTranslation(); - const navigate = useNavigate(); - const { - toolRegistry, - handleToolSelect, - handleToolSelectForced, - toolAvailability, - } = useToolWorkflow(); - const { actions: navActions } = useNavigationActions(); - const { addFiles } = useFileHandler(); - const { config } = useAppConfig(); - // ViewerContext is only present once the viewer subtree mounts; treat as optional. - const viewer = useContext(ViewerContext); +/** Visibility gates shared by the settings and Processor sources. */ +export interface SuperSearchGates { + isAdmin: boolean; + loginEnabled: boolean; +} - const trimmed = query.trim(); +// --------------------------------------------------------------------------- +// 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). +// --------------------------------------------------------------------------- - // --- My Files store ---------------------------------------------------- +/** 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); @@ -120,20 +113,200 @@ export function useSuperSearch( }; }, [active]); + return { stubs, loadingFiles }; +} + +export function rankFileResults( + stubs: StirlingFileStub[], + trimmed: string, + openFile: (stub: StirlingFileStub) => void | Promise, +): SuperSearchResult[] { + if (!trimmed) return []; + return rankByFuzzy(stubs, trimmed, [(s) => s.name]) + .slice(0, GROUP_LIMIT) + .map(({ item, score }) => ({ + key: `file:${item.id}`, + group: "files", + title: item.name, + iconName: "insert-drive-file-rounded", + score, + onSelect: () => openFile(item), + })); +} + +export function rankToolResults( + registry: Partial, + trimmed: string, + openTool: (id: ToolId) => void, +): SuperSearchResult[] { + if (!trimmed) return []; + const entries = Object.entries(registry) as [ + ToolId, + ToolRegistry[ToolId] | undefined, + ][]; + return rankByFuzzy(entries, trimmed, [ + ([id]) => idToWords(id), + ([, v]) => v?.name ?? "", + ([, v]) => v?.description ?? "", + ([, v]) => v?.synonyms?.join(" ") ?? "", + ]) + .slice(0, GROUP_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, + openSettings: (section: string, anchor?: string) => void, +): SuperSearchResult[] { + if (!trimmed) return []; + const { isAdmin, loginEnabled } = gates; + + // Row-level entries (deep-link with ?focus=) take priority. + const rows = rankByFuzzy(SETTINGS_SEARCH_INDEX, trimmed, [ + (e) => t(e.labelKey, e.labelFallback), + (e) => e.labelFallback, + (e) => e.keywords?.join(" ") ?? "", + ]).map(({ item, score }) => ({ + key: `setting:${item.section}:${item.anchor}`, + group: "settings", + title: t(item.labelKey, item.labelFallback), + subtitle: t(`settings.${item.section}.title`, item.section), + iconName: "settings-rounded", + score: score + 1, // nudge rows above bare section matches + onSelect: () => openSettings(item.section, item.anchor), + })); + + // Section-level entries (whole tab), 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 (s.requiresLogin && !loginEnabled) return false; + // Admin-area sections mirror the builder's `isAdmin || !loginEnabled` gate. + if (s.adminArea && !(isAdmin || !loginEnabled)) return false; + return true; + }); + const sections = rankByFuzzy(visibleSections, trimmed, [ + (s) => t(s.labelKey, s.labelFallback), + (s) => s.labelFallback, + (s) => s.keywords?.join(" ") ?? "", + ]).map(({ item, score }) => ({ + key: `setting-section:${item.key}`, + group: "settings", + title: t(item.labelKey, item.labelFallback), + iconName: "settings-rounded", + score, + onSelect: () => openSettings(item.key), + })); + + return [...rows, ...sections] + .sort((a, b) => b.score - a.score) + .slice(0, GROUP_LIMIT); +} + +export function rankProcessorResults( + trimmed: string, + t: TFunction, + gates: SuperSearchGates, + selectEntry: (entry: ProcessorSearchEntry) => void, +): SuperSearchResult[] { + if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return []; + // The portal is an admin surface; mirror the settings modal's admin gate + // (a login-disabled single-user deployment has a full-access operator). + if (!(gates.isAdmin || !gates.loginEnabled)) return []; + return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [ + (e) => t(e.labelKey, e.labelFallback), + (e) => e.labelFallback, + (e) => e.keywords?.join(" ") ?? "", + ]) + .slice(0, GROUP_LIMIT) + .map(({ item, score }) => ({ + key: `processor:${item.id}`, + group: "processor", + title: t(item.labelKey, item.labelFallback), + iconName: "grid-view-rounded", + 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, +): UseSuperSearchResult { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { + toolRegistry, + handleToolSelect, + handleToolSelectForced, + toolAvailability, + } = useToolWorkflow(); + const { actions: navActions } = useNavigationActions(); + const { actions: fileActions } = useFileActions(); + const { config } = useAppConfig(); + // ViewerContext is only present once the viewer subtree mounts; treat as optional. + const viewer = useContext(ViewerContext); + + const trimmed = query.trim(); + const { stubs, loadingFiles } = useMyFilesStubs(active); + // --- Actions ----------------------------------------------------------- const openFile = useCallback( async (stub: StirlingFileStub) => { try { - const file = await fileStorage.getStirlingFile(stub.id); - if (!file) return; - await addFiles([file], { selectFiles: true }); + // 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"); viewer?.setActiveFileId?.(stub.id); } catch (err) { console.error("[SuperSearch] Failed to open file:", stub.name, err); } }, - [addFiles, navActions, viewer], + [fileActions, navActions, viewer], ); const openTool = useCallback( @@ -163,144 +336,54 @@ export function useSuperSearch( [navigate], ); - // --- Files results ----------------------------------------------------- - const fileResults = useMemo(() => { - if (!trimmed) return []; - return rankByFuzzy(stubs, trimmed, [(s) => s.name]) - .slice(0, GROUP_LIMIT) - .map(({ item, score }) => ({ - key: `file:${item.id}`, - group: "files" as const, - title: item.name, - iconName: "insert-drive-file-rounded", - score, - onSelect: () => openFile(item), - })); - }, [trimmed, stubs, openFile]); - - // --- Tools results ----------------------------------------------------- - const toolResults = useMemo(() => { - if (!trimmed) return []; - const entries = Object.entries(toolRegistry) as [ - ToolId, - (typeof toolRegistry)[ToolId], - ][]; - return rankByFuzzy(entries, trimmed, [ - ([id]) => idToWords(id), - ([, v]) => v?.name ?? "", - ([, v]) => v?.description ?? "", - ([, v]) => v?.synonyms?.join(" ") ?? "", - ]) - .slice(0, GROUP_LIMIT) - .map(({ item: [id, tool], score }) => ({ - key: `tool:${id}`, - group: "tools" as const, - title: tool?.name ?? id, - subtitle: tool?.description, - icon: tool?.icon, - score, - onSelect: () => openTool(id), - })); - }, [trimmed, toolRegistry, openTool]); - - // --- Settings results -------------------------------------------------- - const settingsResults = useMemo(() => { - if (!trimmed) return []; - const isAdmin = config?.isAdmin ?? false; - const loginEnabled = config?.enableLogin ?? false; - - // Row-level entries (deep-link with ?focus=) take priority. - const rows = rankByFuzzy(SETTINGS_SEARCH_INDEX, trimmed, [ - (e) => t(e.labelKey, e.labelFallback), - (e) => e.labelFallback, - (e) => e.keywords?.join(" ") ?? "", - ]).map(({ item, score }) => ({ - key: `setting:${item.section}:${item.anchor}`, - group: "settings" as const, - title: t(item.labelKey, item.labelFallback), - subtitle: t(`settings.${item.section}.title`, item.section), - iconName: "settings-rounded", - score: score + 1, // nudge rows above bare section matches - onSelect: () => openSettings(item.section, item.anchor), - })); - - // Section-level entries (whole tab), 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 (s.requiresLogin && !loginEnabled) return false; - // Admin-area sections mirror the builder's `isAdmin || !loginEnabled` gate. - if (s.adminArea && !(isAdmin || !loginEnabled)) return false; - return true; - }); - const sections = rankByFuzzy(visibleSections, trimmed, [ - (s) => t(s.labelKey, s.labelFallback), - (s) => s.labelFallback, - (s) => s.keywords?.join(" ") ?? "", - ]).map(({ item, score }) => ({ - key: `setting-section:${item.key}`, - group: "settings" as const, - title: t(item.labelKey, item.labelFallback), - iconName: "settings-rounded", - score, - onSelect: () => openSettings(item.key), - })); - - return [...rows, ...sections] - .sort((a, b) => b.score - a.score) - .slice(0, GROUP_LIMIT); - }, [trimmed, config, t, openSettings]); - - // --- Processor (admin portal) results ------------------------------------ - const processorResults = useMemo(() => { - if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return []; - // The portal is an admin surface; mirror the settings modal's admin gate - // (a login-disabled single-user deployment has a full-access operator). - const isAdmin = config?.isAdmin ?? false; - const loginEnabled = config?.enableLogin ?? false; - if (!(isAdmin || !loginEnabled)) return []; - return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [ - (e) => t(e.labelKey, e.labelFallback), - (e) => e.labelFallback, - (e) => e.keywords?.join(" ") ?? "", - ]) - .slice(0, GROUP_LIMIT) - .map(({ item, score }) => ({ - key: `processor:${item.id}`, - group: "processor" as const, - title: t(item.labelKey, item.labelFallback), - iconName: "grid-view-rounded", - score, - onSelect: () => { - if (item.externalUrl) { - window.open(item.externalUrl, "_blank", "noopener,noreferrer"); - } else { - navigate(item.path); - } - }, - })); - }, [trimmed, config, t, navigate]); + const selectProcessorEntry = useCallback( + (item: ProcessorSearchEntry) => { + if (item.externalUrl) { + window.open(item.externalUrl, "_blank", "noopener,noreferrer"); + } else { + navigate(item.path); + } + }, + [navigate], + ); // --- Assemble ---------------------------------------------------------- - const groups = useMemo(() => { - const byId: Record = { - files: fileResults, - tools: toolResults, - settings: settingsResults, - processor: processorResults, - }; - 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 GROUP_ORDER.map((id) => ({ - id, - label: labels[id], - results: byId[id], - })).filter((g) => g.results.length > 0); - }, [fileResults, toolResults, settingsResults, processorResults, t]); + const gates = useMemo( + () => ({ + isAdmin: config?.isAdmin ?? false, + loginEnabled: config?.enableLogin ?? false, + }), + [config], + ); + + const groups = useMemo( + () => + assembleSuperSearchGroups( + { + files: rankFileResults(stubs, trimmed, openFile), + tools: rankToolResults(toolRegistry, trimmed, openTool), + settings: rankSettingsResults(trimmed, t, gates, openSettings), + processor: rankProcessorResults( + trimmed, + t, + gates, + selectProcessorEntry, + ), + }, + t, + ), + [ + stubs, + trimmed, + openFile, + toolRegistry, + openTool, + gates, + openSettings, + selectProcessorEntry, + t, + ], + ); const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]); diff --git a/frontend/editor/src/portal/components/PortalSearchBar.tsx b/frontend/editor/src/portal/components/PortalSearchBar.tsx index dd37ac5711..8d1198677c 100644 --- a/frontend/editor/src/portal/components/PortalSearchBar.tsx +++ b/frontend/editor/src/portal/components/PortalSearchBar.tsx @@ -1,16 +1,21 @@ +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; import SuperSearch from "@app/components/shared/superSearch/SuperSearch"; import { usePortalSearchResults } 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's destinations provider. Cmd/Ctrl+K - * focuses it (the bar registers its own shortcut). + * workbench shows, fed by the portal-wired results provider. Cmd/Ctrl+K + * focuses it (the bar registers its own shortcut). The config provider + * supplies the admin/login gates the settings and Processor sources share + * with the editor bar. */ export function PortalSearchBar() { return ( -
- -
+ +
+ +
+
); } diff --git a/frontend/editor/src/portal/hooks/usePortalSearchResults.ts b/frontend/editor/src/portal/hooks/usePortalSearchResults.ts index 0e5ea6552e..d23f904c46 100644 --- a/frontend/editor/src/portal/hooks/usePortalSearchResults.ts +++ b/frontend/editor/src/portal/hooks/usePortalSearchResults.ts @@ -1,62 +1,114 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { rankByFuzzy } from "@app/utils/fuzzySearch"; -import type { UseSuperSearchResult } from "@app/hooks/useSuperSearch"; -import { useView, type ViewId } from "@portal/contexts/ViewContext"; +import { useNavigate } from "react-router-dom"; +import { withBasePath } from "@app/constants/app"; +import { getToolUrlPath } from "@app/data/toolsTaxonomy"; +import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import type { ToolId } from "@app/types/toolId"; +import type { ProcessorSearchEntry } from "@app/data/processorSearchIndex"; import { - GROUP_PRIMARY, - GROUP_OPERATIONAL, - GROUP_PLATFORM, - type NavEntry, -} from "@portal/components/sidebarGroups"; + assembleSuperSearchGroups, + rankProcessorResults, + rankSettingsResults, + rankToolResults, + type SuperSearchGates, + type SuperSearchGroupId, + type UseSuperSearchResult, +} from "@app/hooks/useSuperSearch"; +import { useUI } from "@portal/contexts/UIContext"; +import { EDITOR_IS_SAME_APP, EDITOR_URL } from "@portal/auth/editorUrl"; + +/** Processor pages lead in the portal; the editor bar orders its own first. */ +const PORTAL_GROUP_ORDER: SuperSearchGroupId[] = [ + "processor", + "tools", + "settings", +]; /** - * The portal's results provider for the shared SuperSearch bar: the sidebar's - * flavor-aware destinations plus the editor app. The editor's provider is the - * files/tools/settings/Processor aggregate; this is its portal counterpart. + * Tool results live in the editor app, so selecting one is a full page load + * there (the editor initialises its tool state from the URL on boot — + * client-side routing can't reach that init once mounted). + */ +function editorHref(path: string): string { + if (EDITOR_IS_SAME_APP) return withBasePath(path); + return EDITOR_URL.replace(/\/$/, "") + path; +} + +/** + * The portal's results provider for the shared super search bar: the same + * sources the editor bar ranks minus files (a file only opens inside the + * editor), with Processor pages leading. Only the select actions differ — + * tools hand over to the editor, settings opens the portal's settings modal, + * Processor pages navigate in-app. */ export function usePortalSearchResults( query: string, _active: boolean, ): UseSuperSearchResult { const { t } = useTranslation(); - const { setActiveView } = useView(); + const navigate = useNavigate(); + const { openSettings } = useUI(); + const { allTools } = useToolRegistry(); + const { config } = useAppConfig(); - const entries = useMemo( - () => [ - ...GROUP_PRIMARY, - ...GROUP_OPERATIONAL, - ...GROUP_PLATFORM, - { id: "editor" as ViewId, icon: null }, - ], - [], + const trimmed = query.trim(); + + const openTool = useCallback((id: ToolId) => { + window.location.assign(editorHref(getToolUrlPath(id))); + }, []); + + const openSettingsSection = useCallback( + (section: string) => openSettings(section), + [openSettings], ); - const groups = useMemo(() => { - const q = query.trim(); - if (!q) return []; - const results = rankByFuzzy(entries, q, [ - (e) => t(`portal.nav.${e.id}`), - (e) => e.id, - ]).map(({ item, score }) => ({ - key: `nav:${item.id}`, - group: "nav", - title: t(`portal.nav.${item.id}`), - icon: item.icon ?? undefined, - iconName: item.icon ? undefined : "search-rounded", - score, - onSelect: () => { - if (item.externalUrl) { - window.open(item.externalUrl, "_blank", "noopener,noreferrer"); - return; - } - setActiveView(item.id); - }, - })); - return results.length > 0 - ? [{ id: "nav", label: t("portal.search.goTo", "Go to"), results }] - : []; - }, [entries, query, t, setActiveView]); + const selectProcessorEntry = useCallback( + (item: ProcessorSearchEntry) => { + if (item.externalUrl) { + window.open(item.externalUrl, "_blank", "noopener,noreferrer"); + } else { + navigate(item.path); + } + }, + [navigate], + ); + + const gates = useMemo( + () => ({ + isAdmin: config?.isAdmin ?? false, + loginEnabled: config?.enableLogin ?? false, + }), + [config], + ); + + const groups = useMemo( + () => + assembleSuperSearchGroups( + { + tools: rankToolResults(allTools, trimmed, openTool), + settings: rankSettingsResults(trimmed, t, gates, openSettingsSection), + processor: rankProcessorResults( + trimmed, + t, + gates, + selectProcessorEntry, + ), + }, + t, + PORTAL_GROUP_ORDER, + ), + [ + trimmed, + allTools, + openTool, + gates, + openSettingsSection, + selectProcessorEntry, + t, + ], + ); const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);