diff --git a/apps/web/modules/ee/unify-feedback/topics-subtopics/actions.ts b/apps/web/modules/ee/unify-feedback/topics-subtopics/actions.ts index 0b7d24e6e051..b5398c0d3684 100644 --- a/apps/web/modules/ee/unify-feedback/topics-subtopics/actions.ts +++ b/apps/web/modules/ee/unify-feedback/topics-subtopics/actions.ts @@ -12,14 +12,14 @@ import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/ import { semanticSearchFeedbackRecords } from "@/modules/hub/service"; import type { SemanticSearchResultItem } from "@/modules/hub/types"; -const TOPICS_PREVIEW_LIMIT = 10; +const TOPICS_PREVIEW_PAGE_SIZE = 50; const SEARCH_CONCURRENCY = 4; const ZSemanticSearchFeedbackRecordsAction = z.object({ workspaceId: ZId, query: z.string().trim().min(1).max(500), - limit: z.number().min(1).max(50).optional(), minScore: z.number().min(0).max(1).optional(), + cursors: z.record(z.string(), z.string()).optional(), }); export type TTopicsPreviewSearchResult = SemanticSearchResultItem & { @@ -29,6 +29,7 @@ export type TTopicsPreviewSearchResult = SemanticSearchResultItem & { export type TTopicsPreviewSearchActionResult = { results: TTopicsPreviewSearchResult[]; + cursors: Record; unavailable: boolean; unavailableMessage?: string; }; @@ -70,23 +71,31 @@ export const semanticSearchFeedbackRecordsAction = authenticatedActionClient const directories = await getFeedbackDirectoriesByWorkspaceId(parsedInput.workspaceId); if (directories.length === 0) { - return { results: [], unavailable: false }; + return { results: [], cursors: {}, unavailable: false }; } - const limit = parsedInput.limit ?? TOPICS_PREVIEW_LIMIT; + // On "load more" only re-query directories that returned a cursor on the previous page. + // Filtering against the workspace's directories above also guards against cursor-bag + // tampering — any directory id not in this set is silently dropped. + const targetDirectories = parsedInput.cursors + ? directories.filter((d) => Boolean(parsedInput.cursors?.[d.id])) + : directories; + const searches: { directory: (typeof directories)[number]; result: Awaited>; }[] = []; - for (let i = 0; i < directories.length; i += SEARCH_CONCURRENCY) { - const chunk = directories.slice(i, i + SEARCH_CONCURRENCY); + for (let i = 0; i < targetDirectories.length; i += SEARCH_CONCURRENCY) { + const chunk = targetDirectories.slice(i, i + SEARCH_CONCURRENCY); const chunkResults = await Promise.all( chunk.map(async (directory) => { + const cursor = parsedInput.cursors?.[directory.id]; const result = await semanticSearchFeedbackRecords({ tenant_id: directory.id, query: parsedInput.query, - limit, + limit: TOPICS_PREVIEW_PAGE_SIZE, min_score: parsedInput.minScore, + ...(cursor ? { cursor } : {}), }); return { directory, result }; }) @@ -102,10 +111,28 @@ export const semanticSearchFeedbackRecordsAction = authenticatedActionClient })) ); + const nextCursors: Record = {}; + for (const { directory, result } of searches) { + const nextCursor = result.data?.next_cursor; + if (nextCursor) { + nextCursors[directory.id] = nextCursor; + } + } + + // A directory returning 0/503 is a transient outage we want to surface even when + // other directories returned data — otherwise the failing directory silently drops + // out of nextCursors and stays excluded from every subsequent "load more". + const transientOutage = searches.find(({ result }) => { + const status = result.error?.status; + return status === 0 || status === 503; + })?.result.error; + if (successfulResults.length > 0) { return { - results: successfulResults.toSorted((a, b) => b.score - a.score).slice(0, limit), - unavailable: false, + results: successfulResults.toSorted((a, b) => b.score - a.score), + cursors: nextCursors, + unavailable: Boolean(transientOutage), + ...(transientOutage ? { unavailableMessage: transientOutage.message } : {}), }; } @@ -113,6 +140,7 @@ export const semanticSearchFeedbackRecordsAction = authenticatedActionClient if (firstError?.status === 0 || firstError?.status === 503) { return { results: [], + cursors: {}, unavailable: true, unavailableMessage: firstError.message, }; @@ -122,6 +150,6 @@ export const semanticSearchFeedbackRecordsAction = authenticatedActionClient throw new Error(firstError.message); } - return { results: [], unavailable: false }; + return { results: [], cursors: {}, unavailable: false }; } ); diff --git a/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-preview.tsx b/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-preview.tsx index c67a53e2f074..55dd63747937 100644 --- a/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-preview.tsx +++ b/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-preview.tsx @@ -25,12 +25,20 @@ export const TopicsSubtopicsPreview = ({ }: Readonly) => { const { t } = useTranslation(); const [query, setQuery] = useState(""); + // The query bound to the current results + cursors. Kept separate from `query` (the + // live input) so that editing the input mid-pagination does not corrupt "load more" + // by submitting a different query against the existing cursors. + const [activeQuery, setActiveQuery] = useState(""); const [results, setResults] = useState([]); + const [cursors, setCursors] = useState>({}); const [hasSearched, setHasSearched] = useState(false); const [isSearching, setIsSearching] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); const [unavailableMessage, setUnavailableMessage] = useState(null); + const hasMore = Object.keys(cursors).length > 0; + const hasDirectories = Object.keys(directoryMap).length > 0; const exampleSearches = [ @@ -41,24 +49,26 @@ export const TopicsSubtopicsPreview = ({ const runSearch = async (searchQuery: string) => { const trimmedQuery = searchQuery.trim(); - if (!trimmedQuery || isSearching) return; + if (!trimmedQuery || isSearching || isLoadingMore) return; setQuery(trimmedQuery); + setActiveQuery(trimmedQuery); setIsSearching(true); setHasSearched(true); setError(null); setUnavailableMessage(null); + setCursors({}); try { const response = await semanticSearchFeedbackRecordsAction({ workspaceId, query: trimmedQuery, - limit: 10, minScore: 0.7, }); if (response?.data) { setResults(response.data.results); + setCursors(response.data.cursors); setUnavailableMessage(response.data.unavailable ? (response.data.unavailableMessage ?? "") : null); } else { setResults([]); @@ -77,6 +87,35 @@ export const TopicsSubtopicsPreview = ({ await runSearch(query); }; + const handleLoadMore = async () => { + if (isLoadingMore || isSearching || !hasMore || !activeQuery) return; + setIsLoadingMore(true); + + try { + const response = await semanticSearchFeedbackRecordsAction({ + workspaceId, + query: activeQuery, + minScore: 0.7, + cursors, + }); + + if (response?.data) { + const data = response.data; + setResults((prev) => [...prev, ...data.results]); + setCursors(data.cursors); + if (data.unavailable) { + setUnavailableMessage(data.unavailableMessage ?? ""); + } + } else { + setError(getFormattedErrorMessage(response) ?? t("workspace.unify.semantic_search_failed")); + } + } catch { + setError(t("workspace.unify.semantic_search_failed")); + } finally { + setIsLoadingMore(false); + } + }; + return ( @@ -105,10 +144,13 @@ export const TopicsSubtopicsPreview = ({ value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("workspace.unify.semantic_search_placeholder")} - disabled={!hasDirectories || isSearching} + disabled={!hasDirectories || isSearching || isLoadingMore} aria-label={t("workspace.unify.semantic_search_input_label")} /> - @@ -122,7 +164,7 @@ export const TopicsSubtopicsPreview = ({ type="button" size="sm" variant="secondary" - disabled={!hasDirectories || isSearching} + disabled={!hasDirectories || isSearching || isLoadingMore} onClick={() => runSearch(label)}> {label} @@ -158,32 +200,47 @@ export const TopicsSubtopicsPreview = ({ )} {results.length > 0 && ( -
-
-

- {t("workspace.unify.semantic_search_results_count", { count: results.length })} -

-
-
- {results.map((result) => ( -
-
- - - {t("workspace.unify.semantic_search_relevance", { - score: Math.round(result.score * 100), - })} - +
+
+
+

+ {t("workspace.unify.semantic_search_results_count", { count: results.length })} +

+
+
+ {results.map((result) => ( +
+
+ + + {t("workspace.unify.semantic_search_relevance", { + score: Math.round(result.score * 100), + })} + +
+

+ {result.field_label || t("workspace.unify.field_label")} +

+

+ {result.value_text || t("workspace.unify.semantic_search_missing_text")} +

-

- {result.field_label || t("workspace.unify.field_label")} -

-

- {result.value_text || t("workspace.unify.semantic_search_missing_text")} -

-
- ))} + ))} +
+ + {hasMore && ( +
+ +
+ )}
)}