@@ -25,12 +25,20 @@ export const TopicsSubtopicsPreview = ({
2525} : Readonly < TopicsSubtopicsPreviewProps > ) => {
2626 const { t } = useTranslation ( ) ;
2727 const [ query , setQuery ] = useState ( "" ) ;
28+ // The query bound to the current results + cursors. Kept separate from `query` (the
29+ // live input) so that editing the input mid-pagination does not corrupt "load more"
30+ // by submitting a different query against the existing cursors.
31+ const [ activeQuery , setActiveQuery ] = useState ( "" ) ;
2832 const [ results , setResults ] = useState < TTopicsPreviewSearchResult [ ] > ( [ ] ) ;
33+ const [ cursors , setCursors ] = useState < Record < string , string > > ( { } ) ;
2934 const [ hasSearched , setHasSearched ] = useState ( false ) ;
3035 const [ isSearching , setIsSearching ] = useState ( false ) ;
36+ const [ isLoadingMore , setIsLoadingMore ] = useState ( false ) ;
3137 const [ error , setError ] = useState < string | null > ( null ) ;
3238 const [ unavailableMessage , setUnavailableMessage ] = useState < string | null > ( null ) ;
3339
40+ const hasMore = Object . keys ( cursors ) . length > 0 ;
41+
3442 const hasDirectories = Object . keys ( directoryMap ) . length > 0 ;
3543
3644 const exampleSearches = [
@@ -41,24 +49,26 @@ export const TopicsSubtopicsPreview = ({
4149
4250 const runSearch = async ( searchQuery : string ) => {
4351 const trimmedQuery = searchQuery . trim ( ) ;
44- if ( ! trimmedQuery || isSearching ) return ;
52+ if ( ! trimmedQuery || isSearching || isLoadingMore ) return ;
4553
4654 setQuery ( trimmedQuery ) ;
55+ setActiveQuery ( trimmedQuery ) ;
4756 setIsSearching ( true ) ;
4857 setHasSearched ( true ) ;
4958 setError ( null ) ;
5059 setUnavailableMessage ( null ) ;
60+ setCursors ( { } ) ;
5161
5262 try {
5363 const response = await semanticSearchFeedbackRecordsAction ( {
5464 workspaceId,
5565 query : trimmedQuery ,
56- limit : 10 ,
5766 minScore : 0.7 ,
5867 } ) ;
5968
6069 if ( response ?. data ) {
6170 setResults ( response . data . results ) ;
71+ setCursors ( response . data . cursors ) ;
6272 setUnavailableMessage ( response . data . unavailable ? ( response . data . unavailableMessage ?? "" ) : null ) ;
6373 } else {
6474 setResults ( [ ] ) ;
@@ -77,6 +87,35 @@ export const TopicsSubtopicsPreview = ({
7787 await runSearch ( query ) ;
7888 } ;
7989
90+ const handleLoadMore = async ( ) => {
91+ if ( isLoadingMore || isSearching || ! hasMore || ! activeQuery ) return ;
92+ setIsLoadingMore ( true ) ;
93+
94+ try {
95+ const response = await semanticSearchFeedbackRecordsAction ( {
96+ workspaceId,
97+ query : activeQuery ,
98+ minScore : 0.7 ,
99+ cursors,
100+ } ) ;
101+
102+ if ( response ?. data ) {
103+ const data = response . data ;
104+ setResults ( ( prev ) => [ ...prev , ...data . results ] ) ;
105+ setCursors ( data . cursors ) ;
106+ if ( data . unavailable ) {
107+ setUnavailableMessage ( data . unavailableMessage ?? "" ) ;
108+ }
109+ } else {
110+ setError ( getFormattedErrorMessage ( response ) ?? t ( "workspace.unify.semantic_search_failed" ) ) ;
111+ }
112+ } catch {
113+ setError ( t ( "workspace.unify.semantic_search_failed" ) ) ;
114+ } finally {
115+ setIsLoadingMore ( false ) ;
116+ }
117+ } ;
118+
80119 return (
81120 < PageContentWrapper >
82121 < PageHeader pageTitle = { t ( "workspace.unify.feedback_records" ) } >
@@ -105,10 +144,13 @@ export const TopicsSubtopicsPreview = ({
105144 value = { query }
106145 onChange = { ( event ) => setQuery ( event . target . value ) }
107146 placeholder = { t ( "workspace.unify.semantic_search_placeholder" ) }
108- disabled = { ! hasDirectories || isSearching }
147+ disabled = { ! hasDirectories || isSearching || isLoadingMore }
109148 aria-label = { t ( "workspace.unify.semantic_search_input_label" ) }
110149 />
111- < Button type = "submit" disabled = { ! query . trim ( ) || ! hasDirectories } loading = { isSearching } >
150+ < Button
151+ type = "submit"
152+ disabled = { ! query . trim ( ) || ! hasDirectories || isLoadingMore }
153+ loading = { isSearching } >
112154 < SearchIcon className = "size-4" aria-hidden = "true" />
113155 { t ( "workspace.unify.search_feedback" ) }
114156 </ Button >
@@ -122,7 +164,7 @@ export const TopicsSubtopicsPreview = ({
122164 type = "button"
123165 size = "sm"
124166 variant = "secondary"
125- disabled = { ! hasDirectories || isSearching }
167+ disabled = { ! hasDirectories || isSearching || isLoadingMore }
126168 onClick = { ( ) => runSearch ( label ) } >
127169 { label }
128170 </ Button >
@@ -158,32 +200,47 @@ export const TopicsSubtopicsPreview = ({
158200 ) }
159201
160202 { results . length > 0 && (
161- < div className = "overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm" >
162- < div className = "border-b border-slate-200 px-4 py-3" >
163- < p className = "text-sm font-medium text-slate-900" >
164- { t ( "workspace.unify.semantic_search_results_count" , { count : results . length } ) }
165- </ p >
166- </ div >
167- < div className = "divide-y divide-slate-100" >
168- { results . map ( ( result ) => (
169- < div key = { `${ result . tenant_id } -${ result . feedback_record_id } ` } className = "space-y-2 p-4" >
170- < div className = "flex flex-wrap items-center gap-2" >
171- < Badge text = { result . directory_name } type = "gray" size = "tiny" />
172- < span className = "text-xs text-slate-500" >
173- { t ( "workspace.unify.semantic_search_relevance" , {
174- score : Math . round ( result . score * 100 ) ,
175- } ) }
176- </ span >
203+ < div className = "space-y-3" >
204+ < div className = "overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm" >
205+ < div className = "border-b border-slate-200 px-4 py-3" >
206+ < p className = "text-sm font-medium text-slate-900" >
207+ { t ( "workspace.unify.semantic_search_results_count" , { count : results . length } ) }
208+ </ p >
209+ </ div >
210+ < div className = "divide-y divide-slate-100" >
211+ { results . map ( ( result ) => (
212+ < div key = { `${ result . tenant_id } -${ result . feedback_record_id } ` } className = "space-y-2 p-4" >
213+ < div className = "flex flex-wrap items-center gap-2" >
214+ < Badge text = { result . directory_name } type = "gray" size = "tiny" />
215+ < span className = "text-xs text-slate-500" >
216+ { t ( "workspace.unify.semantic_search_relevance" , {
217+ score : Math . round ( result . score * 100 ) ,
218+ } ) }
219+ </ span >
220+ </ div >
221+ < p className = "text-sm font-medium text-slate-900" >
222+ { result . field_label || t ( "workspace.unify.field_label" ) }
223+ </ p >
224+ < p className = "whitespace-pre-wrap text-sm text-slate-700" >
225+ { result . value_text || t ( "workspace.unify.semantic_search_missing_text" ) }
226+ </ p >
177227 </ div >
178- < p className = "text-sm font-medium text-slate-900" >
179- { result . field_label || t ( "workspace.unify.field_label" ) }
180- </ p >
181- < p className = "whitespace-pre-wrap text-sm text-slate-700" >
182- { result . value_text || t ( "workspace.unify.semantic_search_missing_text" ) }
183- </ p >
184- </ div >
185- ) ) }
228+ ) ) }
229+ </ div >
186230 </ div >
231+
232+ { hasMore && (
233+ < div className = "flex justify-center" >
234+ < Button
235+ variant = "secondary"
236+ size = "sm"
237+ onClick = { handleLoadMore }
238+ disabled = { isLoadingMore || isSearching }
239+ loading = { isLoadingMore } >
240+ { t ( "common.load_more" ) }
241+ </ Button >
242+ </ div >
243+ ) }
187244 </ div >
188245 ) }
189246 </ div >
0 commit comments