@@ -5,7 +5,8 @@ import type {
55 ScopedThreadRef ,
66} from "@t3tools/contracts" ;
77import { scopedThreadKey } from "@t3tools/client-runtime/environment" ;
8- import { useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from "react" ;
8+ import { useEffect , useLayoutEffect , useRef , useState , type RefObject } from "react" ;
9+ import { useStableCallback } from "~/hooks/useStableCallback" ;
910import { orchestrationEnvironment } from "~/state/orchestration" ;
1011import { useEnvironmentQuery } from "~/state/query" ;
1112import { useDebouncedValue } from "~/state/queries" ;
@@ -49,7 +50,10 @@ export function useThreadFind({
4950 skills ?: readonly InlineSkill [ ] ;
5051 content : Pick < OrchestrationV2ThreadProjection , "visibleTurnItems" | "runs" > | undefined ;
5152} ) {
52- const findPositionReaderRef = useRef < ThreadFindPositionReader | null > ( null ) ;
53+ // Held in state so the timeline props that carry it can be stored in state too.
54+ const [ positionReader ] = useState < RefObject < ThreadFindPositionReader | null > > ( ( ) => ( {
55+ current : null ,
56+ } ) ) ;
5357 const lastContentRef = useRef ( content ) ;
5458 const refreshTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
5559 const refreshAfterSearchRef = useRef ( false ) ;
@@ -58,7 +62,7 @@ export function useThreadFind({
5862 if ( state . threadKey !== null && ( ! enabled || state . threadKey !== threadKey ) )
5963 setState ( CLOSED_FIND ) ;
6064 const isOpen = enabled && threadKey !== null && state . threadKey === threadKey ;
61- const open = useCallback ( ( ) => {
65+ const open = useStableCallback ( ( ) => {
6266 if ( threadKey === null ) return ;
6367 if ( ! enabled ) {
6468 toastManager . add ( {
@@ -70,7 +74,7 @@ export function useThreadFind({
7074 }
7175 const start =
7276 state . threadKey === threadKey && state . query . trim ( )
73- ? findPositionReaderRef . current ?.( state . query . trim ( ) )
77+ ? positionReader . current ?.( state . query . trim ( ) )
7478 : undefined ;
7579 setState ( ( previous ) => {
7680 const current = previous . threadKey === threadKey ? previous : CLOSED_FIND ;
@@ -89,8 +93,8 @@ export function useThreadFind({
8993 : { } ) ,
9094 } ;
9195 } ) ;
92- } , [ enabled , state . query , state . threadKey , threadKey ] ) ;
93- const close = useCallback ( ( ) => setState ( CLOSED_FIND ) , [ ] ) ;
96+ } ) ;
97+ const close = useStableCallback ( ( ) => setState ( CLOSED_FIND ) ) ;
9498 useEffect ( ( ) => subscribeThreadFindOpen ( open ) , [ open ] ) ;
9599
96100 const remote = useServerResults (
@@ -118,16 +122,35 @@ export function useThreadFind({
118122 const entryId = selectedMatch ?. entryId ;
119123 const runId = selectedMatch ?. runId ;
120124 const occurrence = selectedMatch ?. occurrence ;
121- // Finishing the count must not rerender the timeline or request another jump.
122- const match = useMemo (
123- ( ) =>
125+ const counting = remote . data ?. complete === false ;
126+ const navigationId = localSelection ?. navigationId ?? remote . navigationId ;
127+ const findExpanded = isOpen && state . query . trim ( ) . length > 0 ;
128+ const findQuery = isOpen && remote . data ?. match ? state . query : "" ;
129+ const nextTimeline = {
130+ findOpen : isOpen ,
131+ findExpanded,
132+ findQuery,
133+ activeFindMatch :
124134 entryId !== undefined && occurrence !== undefined
125135 ? { entryId, runId : runId ?? null , occurrence }
126136 : null ,
127- [ entryId , runId , occurrence ] ,
128- ) ;
129- const counting = remote . data ?. complete === false ;
130- const navigationId = localSelection ?. navigationId ?? remote . navigationId ;
137+ findNavigationId : navigationId ,
138+ } ;
139+ // Finishing the count or a pending response must not rerender the timeline or
140+ // request another jump, so these keep their identity until a field changes.
141+ const [ storedTimeline , setTimeline ] = useState ( ( ) => ( {
142+ fields : nextTimeline ,
143+ props : { ...nextTimeline , findPositionReaderRef : positionReader } ,
144+ } ) ) ;
145+ let timeline = storedTimeline ;
146+ if ( ! sameTimeline ( timeline . fields , nextTimeline ) ) {
147+ timeline = {
148+ fields : nextTimeline ,
149+ props : { ...nextTimeline , findPositionReaderRef : positionReader } ,
150+ } ;
151+ setTimeline ( timeline ) ;
152+ }
153+ const match = timeline . fields . activeFindMatch ;
131154 const step = ( delta : number ) => {
132155 if ( count === 0 || counting ) return ;
133156 setState ( ( previous ) => {
@@ -241,20 +264,6 @@ export function useThreadFind({
241264 } ;
242265 } , [ threadKey , isOpen , state . query ] ) ;
243266
244- const findExpanded = isOpen && state . query . trim ( ) . length > 0 ;
245- const findQuery = isOpen && remote . data ?. match ? state . query : "" ;
246- const timelineProps = useMemo (
247- ( ) => ( {
248- findOpen : isOpen ,
249- findExpanded,
250- findPositionReaderRef,
251- findQuery,
252- activeFindMatch : match ,
253- findNavigationId : navigationId ,
254- } ) ,
255- [ isOpen , findExpanded , findQuery , match , navigationId ] ,
256- ) ;
257-
258267 return {
259268 isOpen,
260269 open,
@@ -269,7 +278,7 @@ export function useThreadFind({
269278 focusRequestId : state . focusRequestId ,
270279 onRetry : remote . refresh ,
271280 onQueryChange : ( query : string ) => {
272- const start = findPositionReaderRef . current ?.( query . trim ( ) ) ;
281+ const start = positionReader . current ?.( query . trim ( ) ) ;
273282 setState ( ( previous ) => ( {
274283 ...previous ,
275284 query,
@@ -284,10 +293,31 @@ export function useThreadFind({
284293 onPrevious : ( ) => step ( - 1 ) ,
285294 onClose : close ,
286295 } ,
287- timelineProps,
296+ timelineProps : timeline . props ,
288297 } ;
289298}
290299
300+ type TimelineFields = Omit <
301+ ReturnType < typeof useThreadFind > [ "timelineProps" ] ,
302+ "findPositionReaderRef"
303+ > ;
304+
305+ function sameTimeline ( a : TimelineFields , b : TimelineFields ) {
306+ const aMatch = a . activeFindMatch ;
307+ const bMatch = b . activeFindMatch ;
308+ return (
309+ a . findOpen === b . findOpen &&
310+ a . findExpanded === b . findExpanded &&
311+ a . findQuery === b . findQuery &&
312+ a . findNavigationId === b . findNavigationId &&
313+ ( aMatch === null || bMatch === null
314+ ? aMatch === bMatch
315+ : aMatch . entryId === bMatch . entryId &&
316+ aMatch . runId === bMatch . runId &&
317+ aMatch . occurrence === bMatch . occurrence )
318+ ) ;
319+ }
320+
291321function rebaseLocalSelection (
292322 selection : typeof CLOSED_FIND . localSelection ,
293323 data : OrchestrationV2SearchThreadResult | null ,
@@ -311,41 +341,36 @@ function useServerResults(
311341 skills : readonly InlineSkill [ ] ,
312342 progressive : boolean ,
313343) {
314- const skillLabels = useMemo (
315- ( ) => skills . map ( ( { name, displayName } ) => ( { name, displayName } ) ) ,
316- [ skills ] ,
317- ) ;
344+ const skillLabels = skills . map ( ( { name, displayName } ) => ( { name, displayName } ) ) ;
318345 const normalizedQuery = query . trim ( ) ;
319346 const debouncedQuery = useDebouncedValue ( normalizedQuery , 100 ) ;
320347 const environmentId = thread ?. environmentId ;
321348 const threadId = thread ?. threadId ;
322- const atom = useMemo (
323- ( ) =>
324- environmentId && threadId && debouncedQuery && normalizedQuery === debouncedQuery
325- ? ( progressive
326- ? orchestrationEnvironment . threadFindProgressive
327- : orchestrationEnvironment . threadFind ) ( {
328- environmentId,
329- input : {
330- threadId,
331- query : debouncedQuery ,
332- ...( start ? { start } : { } ) ,
333- ...( offset !== 0 ? { offset } : { } ) ,
334- skills : skillLabels ,
335- } ,
336- } )
337- : null ,
338- [
339- environmentId ,
340- threadId ,
341- debouncedQuery ,
342- normalizedQuery ,
343- start ,
344- offset ,
345- skillLabels ,
346- progressive ,
347- ] ,
348- ) ;
349+ const target =
350+ environmentId && threadId && debouncedQuery && normalizedQuery === debouncedQuery
351+ ? {
352+ environmentId,
353+ input : {
354+ threadId,
355+ query : debouncedQuery ,
356+ ...( start ? { start } : { } ) ,
357+ ...( offset !== 0 ? { offset } : { } ) ,
358+ skills : skillLabels ,
359+ } ,
360+ }
361+ : null ;
362+ const targetKey = target ? JSON . stringify ( [ progressive , target ] ) : null ;
363+ // Resolving the atom only when the request changes keeps unrelated renders from
364+ // touching the atom family.
365+ const [ storedAtom , setStoredAtom ] = useState ( ( ) => ( {
366+ key : targetKey ,
367+ atom : resolveThreadFindAtom ( target , progressive ) ,
368+ } ) ) ;
369+ let atom = storedAtom . atom ;
370+ if ( storedAtom . key !== targetKey ) {
371+ atom = resolveThreadFindAtom ( target , progressive ) ;
372+ setStoredAtom ( { key : targetKey , atom } ) ;
373+ }
349374 const result = useEnvironmentQuery ( atom ) ;
350375 const key = thread
351376 ? JSON . stringify ( [ thread . environmentId , thread . threadId , normalizedQuery , skillLabels ] )
@@ -378,3 +403,13 @@ function useServerResults(
378403 ( normalizedQuery !== debouncedQuery || result . isPending ) ,
379404 } ;
380405}
406+
407+ function resolveThreadFindAtom (
408+ target : Parameters < typeof orchestrationEnvironment . threadFind > [ 0 ] | null ,
409+ progressive : boolean ,
410+ ) {
411+ if ( ! target ) return null ;
412+ return progressive
413+ ? orchestrationEnvironment . threadFindProgressive ( target )
414+ : orchestrationEnvironment . threadFind ( target ) ;
415+ }
0 commit comments