@@ -38,6 +38,7 @@ import { UsageBanner } from './UsageBanner';
3838import { useWebSocket , type DashboardData } from './hooks/useWebSocket' ;
3939import { useAgents } from './hooks/useAgents' ;
4040import { useMessages } from './hooks/useMessages' ;
41+ import { useThread } from './hooks/useThread' ;
4142import { useOrchestrator } from './hooks/useOrchestrator' ;
4243import { useTrajectory } from './hooks/useTrajectory' ;
4344import { useRecentRepos } from './hooks/useRecentRepos' ;
@@ -232,19 +233,27 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
232233 }
233234 } , [ wsError , wsData , restData ] ) ;
234235
235- // Local reaction overrides for optimistic UI updates
236- const [ reactionOverrides , setReactionOverrides ] = useState < Map < string , Reaction [ ] > > ( new Map ( ) ) ;
236+ // Local reaction overrides for optimistic UI updates (TTL-based expiry)
237+ const REACTION_OVERRIDE_TTL = 5000 ; // 5s — enough for API round-trip + WS echo
238+ const [ reactionOverrides , setReactionOverrides ] = useState < Map < string , { reactions : Reaction [ ] ; timestamp : number } > > ( new Map ( ) ) ;
237239
238240 // Use WebSocket data if available, otherwise fall back to REST data
239241 // Merge in local reaction overrides
240242 const rawData = wsData || restData ;
241243 const rawDataRef = useRef ( rawData ) ;
242244 rawDataRef . current = rawData ;
243245
244- // Clear stale reaction overrides when WebSocket delivers fresh data
246+ // Expire stale reaction overrides when WebSocket delivers fresh data
245247 useEffect ( ( ) => {
246248 if ( rawData && reactionOverrides . size > 0 ) {
247- setReactionOverrides ( new Map ( ) ) ;
249+ const now = Date . now ( ) ;
250+ setReactionOverrides ( ( prev ) => {
251+ const next = new Map < string , { reactions : Reaction [ ] ; timestamp : number } > ( ) ;
252+ for ( const [ id , entry ] of prev ) {
253+ if ( now - entry . timestamp < REACTION_OVERRIDE_TTL ) next . set ( id , entry ) ;
254+ }
255+ return next . size === prev . size ? prev : next ;
256+ } ) ;
248257 }
249258 // eslint-disable-next-line react-hooks/exhaustive-deps
250259 } , [ rawData ] ) ;
@@ -254,8 +263,8 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
254263 return {
255264 ...rawData ,
256265 messages : rawData . messages . map ( ( msg ) => {
257- const override = reactionOverrides . get ( msg . id ) ;
258- return override ? { ...msg , reactions : override } : msg ;
266+ const entry = reactionOverrides . get ( msg . id ) ;
267+ return entry ? { ...msg , reactions : entry . reactions } : msg ;
259268 } ) ,
260269 } ;
261270 } , [ rawData , reactionOverrides ] ) ;
@@ -1011,6 +1020,12 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
10111020 senderName : currentUser ?. displayName ,
10121021 } ) ;
10131022
1023+ // Thread data (API-backed with client-side fallback)
1024+ const thread = useThread ( {
1025+ threadId : currentThread ,
1026+ fallbackMessages : messages ,
1027+ } ) ;
1028+
10141029 // Human context (DM inline view)
10151030 const currentHuman = useMemo ( ( ) => {
10161031 if ( ! currentChannel ) return null ;
@@ -2014,7 +2029,8 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
20142029 setReactionOverrides ( ( prev ) => {
20152030 const next = new Map ( prev ) ;
20162031 const msg = rawDataRef . current ?. messages . find ( ( m : Message ) => m . id === messageId ) ;
2017- const current = prev . get ( messageId ) || msg ?. reactions || [ ] ;
2032+ const prevEntry = prev . get ( messageId ) ;
2033+ const current = prevEntry ?. reactions || msg ?. reactions || [ ] ;
20182034 let updated : Reaction [ ] ;
20192035
20202036 if ( hasReacted ) {
@@ -2038,7 +2054,7 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
20382054 }
20392055 }
20402056
2041- next . set ( messageId , updated ) ;
2057+ next . set ( messageId , { reactions : updated , timestamp : Date . now ( ) } ) ;
20422058 return next ;
20432059 } ) ;
20442060
@@ -2967,7 +2983,6 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
29672983
29682984 { /* Thread Panel */ }
29692985 { currentThread && ( ( ) => {
2970- // Determine which message list to search based on view mode
29712986 const isChannelView = viewMode === 'channels' ;
29722987
29732988 // Helper to convert ChannelMessage to Message format for ThreadPanel
@@ -2984,9 +2999,14 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
29842999 } ) ;
29853000
29863001 let originalMessage : Message | null = null ;
3002+ let replies : Message [ ] = [ ] ;
29873003 let isTopicThread = false ;
3004+ let threadIsLoading = false ;
3005+ let threadHasMore = false ;
3006+ let threadLoadMore : ( ( ) => void ) | undefined ;
29883007
29893008 if ( isChannelView ) {
3009+ // Channel view: use inline filtering (useThread doesn't handle ChannelApiMessage)
29903010 const channelMsg = effectiveChannelMessages . find ( ( m ) => m . id === currentThread ) ;
29913011 if ( channelMsg ) {
29923012 originalMessage = convertChannelMessage ( channelMsg ) ;
@@ -2999,42 +3019,36 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
29993019 originalMessage = convertChannelMessage ( threadMsgs [ 0 ] ) ;
30003020 }
30013021 }
3022+ replies = effectiveChannelMessages
3023+ . filter ( ( m ) => m . threadId === currentThread )
3024+ . map ( convertChannelMessage ) ;
30023025 } else {
3003- originalMessage = messages . find ( ( m ) => m . id === currentThread ) ?? null ;
3026+ // Non-channel view: use the useThread hook (API-backed with fallback)
3027+ originalMessage = thread . parentMessage ;
3028+ replies = thread . replies ;
30043029 isTopicThread = ! originalMessage ;
3005- if ( ! originalMessage ) {
3006- const threadMsgs = messages
3007- . filter ( ( m ) => m . thread === currentThread )
3008- . sort ( ( a , b ) => new Date ( a . timestamp ) . getTime ( ) - new Date ( b . timestamp ) . getTime ( ) ) ;
3009- originalMessage = threadMsgs [ 0 ] ?? null ;
3010- }
3030+ threadIsLoading = thread . isLoading ;
3031+ threadHasMore = thread . hasMore ;
3032+ threadLoadMore = thread . loadMore ;
30113033 }
30123034
3013- // Get thread replies based on view mode
3014- const replies : Message [ ] = isChannelView
3015- ? effectiveChannelMessages
3016- . filter ( ( m ) => m . threadId === currentThread )
3017- . map ( convertChannelMessage )
3018- : threadMessages ( currentThread ) ;
3019-
30203035 return (
30213036 < div className = "w-full md:w-[400px] md:min-w-[320px] md:max-w-[500px] flex-shrink-0" >
30223037 < ThreadPanel
30233038 originalMessage = { originalMessage }
30243039 replies = { replies }
30253040 onClose = { ( ) => setCurrentThread ( null ) }
30263041 showTimestamps = { settings . display . showTimestamps }
3042+ isLoading = { threadIsLoading }
3043+ hasMore = { threadHasMore }
3044+ onLoadMore = { threadLoadMore }
30273045 onReply = { async ( content ) => {
30283046 if ( isChannelView && selectedChannel ) {
3029- // For channels, send threaded message
30303047 await handleSendChannelMessage ( content , currentThread ) ;
30313048 return true ;
30323049 }
3033- // For topic threads, broadcast to all; for reply chains, reply to the other participant
30343050 let recipient = '*' ;
30353051 if ( ! isTopicThread && originalMessage ) {
3036- // If current user sent the original message, reply to the recipient
3037- // If someone else sent it, reply to the sender
30383052 const isFromCurrentUser = originalMessage . from === 'Dashboard' ||
30393053 ( currentUser && originalMessage . from === currentUser . displayName ) ;
30403054 recipient = isFromCurrentUser
0 commit comments