11import debugFactory from 'debug' ;
22import { type ReactNode , useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
3+ import { useStore } from 'react-redux' ;
34import { useLocation , useNavigate , useParams } from 'react-router-dom' ;
45
56import { type ChatSendError , chatSendError } from '../../chat/chatSendError' ;
@@ -59,6 +60,7 @@ import {
5960 useRustChat ,
6061} from '../../services/chatService' ;
6162import { callCoreRpc } from '../../services/coreRpcClient' ;
63+ import type { RootState } from '../../store' ;
6264import {
6365 beginInferenceTurn ,
6466 clearRuntimeForThread ,
@@ -79,6 +81,7 @@ import {
7981 clearThreadInferenceActive ,
8082 createNewThread ,
8183 deleteThread ,
84+ invalidateThreadSelection ,
8285 loadThreadMessages ,
8386 loadThreads ,
8487 markThreadInferenceActive ,
@@ -259,6 +262,7 @@ const Conversations = ({
259262 const composer = composerOverride ?? composerProp ;
260263 const { t } = useT ( ) ;
261264 const dispatch = useAppDispatch ( ) ;
265+ const store = useStore < RootState > ( ) ;
262266 const navigate = useNavigate ( ) ;
263267 const location = useLocation ( ) ;
264268 const { threadId : routeThreadId } = useParams < { threadId ?: string } > ( ) ;
@@ -347,10 +351,9 @@ const Conversations = ({
347351 const persistedFailedMessageByThreadRef = useRef < Map < string , ThreadMessage > > ( new Map ( ) ) ;
348352 const createThreadErrorRef = useRef ( createThreadError ) ;
349353 createThreadErrorRef . current = createThreadError ;
350- // Startup thread restoration is asynchronous. If the user chooses a thread
351- // while its initial `loadThreads` is in flight, that older callback must not
352- // overwrite the newer selection when it resolves.
353- const threadSelectionIntentRef = useRef ( 0 ) ;
354+ // The Redux selection intent is updated by every setSelectedThread action,
355+ // including worker-thread cards rendered inside the transcript. Async
356+ // startup/create continuations compare against it before selecting a thread.
354357 const displayedSendError = deriveChatErrorBanner (
355358 sendError ,
356359 createThreadError ,
@@ -726,11 +729,19 @@ const Conversations = ({
726729 const turnSignatureByThreadRef = useRef < Map < string , readonly unknown [ ] > > ( new Map ( ) ) ;
727730
728731 const handleCreateNewThread = async ( fromInitialLoad = false ) => {
729- if ( ! fromInitialLoad ) threadSelectionIntentRef . current += 1 ;
730- const selectionIntentAtCreate = threadSelectionIntentRef . current ;
732+ if ( ! fromInitialLoad ) dispatch ( invalidateThreadSelection ( ) ) ;
733+ const selectionIntentAtCreate = store . getState ( ) . thread . selectionIntentVersion ?? 0 ;
731734 try {
732735 const thread = await dispatch ( createNewThread ( ) ) . unwrap ( ) ;
733- if ( threadSelectionIntentRef . current !== selectionIntentAtCreate ) return ;
736+ const currentSelectionIntent = store . getState ( ) . thread . selectionIntentVersion ?? 0 ;
737+ if ( currentSelectionIntent !== selectionIntentAtCreate ) {
738+ debug (
739+ '[chat] create thread selection superseded; dropping result intent_at_create=%d current_intent=%d' ,
740+ selectionIntentAtCreate ,
741+ currentSelectionIntent
742+ ) ;
743+ return ;
744+ }
734745 dispatch ( setSelectedThread ( thread . id ) ) ;
735746 void dispatch ( loadThreadMessages ( thread . id ) ) ;
736747 if ( shouldSyncChatRoute ) {
@@ -790,12 +801,21 @@ const Conversations = ({
790801
791802 useEffect ( ( ) => {
792803 let cancelled = false ;
793- const selectionIntentAtLoad = threadSelectionIntentRef . current ;
804+ const selectionIntentAtLoad = store . getState ( ) . thread . selectionIntentVersion ?? 0 ;
794805
795806 void dispatch ( loadThreads ( ) )
796807 . unwrap ( )
797808 . then ( data => {
798- if ( cancelled || threadSelectionIntentRef . current !== selectionIntentAtLoad ) return ;
809+ const currentSelectionIntent = store . getState ( ) . thread . selectionIntentVersion ?? 0 ;
810+ if ( cancelled || currentSelectionIntent !== selectionIntentAtLoad ) {
811+ debug (
812+ '[chat] initial thread load selection superseded; dropping result cancelled=%s intent_at_load=%d current_intent=%d' ,
813+ cancelled ,
814+ selectionIntentAtLoad ,
815+ currentSelectionIntent
816+ ) ;
817+ return ;
818+ }
799819 // Match the sidebar's default General filter here so initial/resume
800820 // selection can't auto-pick a thread hidden by the selected tab.
801821 const visibleThreads = data . threads . filter ( t => isThreadVisibleInTab ( t , GENERAL_TAB_VALUE ) ) ;
@@ -1889,7 +1909,6 @@ const Conversations = ({
18891909 selectedThreadId = { selectedThreadId ?? null }
18901910 onCreateThread = { ( ) => handleCreateNewThread ( ) }
18911911 onSelectThread = { id => {
1892- threadSelectionIntentRef . current += 1 ;
18931912 dispatch ( setSelectedThread ( id ) ) ;
18941913 void dispatch ( loadThreadMessages ( id ) ) ;
18951914 if ( shouldSyncChatRoute ) {
@@ -2257,7 +2276,6 @@ const Conversations = ({
22572276 type = "button"
22582277 data-analytics-id = "chat-header-back-to-parent-thread"
22592278 onClick = { ( ) => {
2260- threadSelectionIntentRef . current += 1 ;
22612279 dispatch ( setSelectedThread ( selectedThreadParent . id ) ) ;
22622280 void dispatch ( loadThreadMessages ( selectedThreadParent . id ) ) ;
22632281 navigate ( chatThreadPath ( selectedThreadParent . id ) ) ;
0 commit comments