11'use client'
22
3- import { memo , useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react'
3+ import {
4+ memo ,
5+ type ReactNode ,
6+ useCallback ,
7+ useEffect ,
8+ useLayoutEffect ,
9+ useMemo ,
10+ useRef ,
11+ useState ,
12+ } from 'react'
413import { cn } from '@sim/emcn'
514import { Read as ReadTool , WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
615import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
@@ -21,8 +30,6 @@ import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils'
2130const FILE_SUBAGENT_ID = 'file'
2231/** Quiet period before the shimmer takes the slot back from streamed output. */
2332const STREAM_IDLE_DELAY_MS = 1_500
24- /** Unmount the loader after the slot's 300ms collapse, with margin. */
25- const SLOT_EXIT_DELAY_MS = 400
2633
2734interface TextSegment {
2835 type : 'text'
@@ -785,6 +792,14 @@ interface MessageContentProps {
785792 onOptionSelect ?: ( id : string ) => void
786793 onQuestionDismiss ?: ( ) => void
787794 onPhaseChange ?: ( phase : MessagePhase ) => void
795+ /**
796+ * The message's actions row (copy/thumbs). Rendered here, in the thinking
797+ * slot's position, so at settle the shimmer and the actions trade places in
798+ * one render — a single tiny reflow instead of a collapse the buttons ride
799+ * or a late mount the chase visibly scrolls to. The caller gates it on
800+ * content/question eligibility only; the settle timing is owned here.
801+ */
802+ actions ?: ReactNode
788803}
789804
790805function MessageContentInner ( {
@@ -796,6 +811,7 @@ function MessageContentInner({
796811 onOptionSelect,
797812 onQuestionDismiss,
798813 onPhaseChange,
814+ actions,
799815} : MessageContentProps ) {
800816 const { onWorkspaceResourceSelect } = useChatSurface ( )
801817 const parsed = useMemo ( ( ) => ( blocks . length > 0 ? parseBlocks ( blocks ) : [ ] ) , [ blocks ] )
@@ -813,16 +829,6 @@ function MessageContentInner({
813829 setTrailingPendingTag ( pending )
814830 } , [ ] )
815831 const [ isStreamIdle , setIsStreamIdle ] = useState ( false )
816- /**
817- * True once the slot's collapse has finished (seeded true so a settled mount
818- * never runs the loader). The loader must stay mounted through the collapse:
819- * with `grid-rows` transitioning 1fr→0fr, unmounting the content in the same
820- * flip zeroes the track instantly — the slot snaps instead of sliding out,
821- * clamping `scrollTop` at settle. Timed rather than transitionend-latched:
822- * motion-reduce (`transition-none`) and browsers that can't animate
823- * `grid-template-rows` never fire the event.
824- */
825- const [ slotExited , setSlotExited ] = useState ( true )
826832
827833 const segments : MessageSegment [ ] =
828834 parsed . length > 0
@@ -864,15 +870,6 @@ function MessageContentInner({
864870 // winking out early while everything shifts.
865871 const thinkingExpanded = phase !== 'settled' && lastSegment ?. type !== 'stopped'
866872
867- useEffect ( ( ) => {
868- if ( thinkingExpanded ) return
869- const timeout = setTimeout ( ( ) => setSlotExited ( true ) , SLOT_EXIT_DELAY_MS )
870- return ( ) => clearTimeout ( timeout )
871- } , [ thinkingExpanded ] )
872-
873- // Guarded render adjust (see sim-hooks): a live turn re-arms the exit latch.
874- if ( thinkingExpanded && slotExited ) setSlotExited ( false )
875-
876873 if ( segments . length === 0 && ! isLast ) return null
877874
878875 // A visible executing tool row already spins — the turn-level shimmer would
@@ -956,37 +953,26 @@ function MessageContentInner({
956953 }
957954 } ) }
958955 </ div >
959- { isLast && (
956+ { thinkingExpanded && isLast ? (
960957 // Fixed-height placeholder for the NEXT piece of output: the shimmer
961958 // and arriving output trade places via opacity only, so mid-turn swaps
962- // can't move layout; the grid-rows collapse slides it out once, at
963- // settle. A sibling of the space-y stack (not a child), so collapsed
964- // it carries no leftover sibling margin — pt-[10px] is its own gap.
965- < div
966- aria-hidden = { ! showShimmer }
967- className = { cn (
968- 'grid transition-[grid-template-rows,opacity] duration-300 ease-out motion-reduce:transition-none' ,
969- thinkingExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
970- ) }
971- >
972- < div className = 'overflow-hidden' >
973- < div
974- className = { cn (
975- 'pt-[10px] transition-transform duration-300 ease-out motion-reduce:transition-none' ,
976- thinkingExpanded ? 'translate-y-0' : 'translate-y-[8px]'
977- ) }
978- >
979- < div
980- className = { cn (
981- 'transition-opacity duration-200 ease-out' ,
982- showShimmer ? 'opacity-100' : 'opacity-0'
983- ) }
984- >
985- { ! slotExited && < PendingTagIndicator label = { thinkingLabel ?? 'Thinking…' } /> }
986- </ div >
987- </ div >
959+ // can't move layout. A sibling of the space-y stack (not a child), so
960+ // it carries no stray sibling margin — pt-[10px] is its own gap.
961+ < div aria-hidden = { ! showShimmer } className = 'pt-[10px]' >
962+ < div
963+ className = { cn (
964+ 'transition-opacity duration-200 ease-out' ,
965+ showShimmer ? 'opacity-100' : 'opacity-0'
966+ ) }
967+ >
968+ < PendingTagIndicator label = { thinkingLabel ?? 'Thinking…' } />
988969 </ div >
989970 </ div >
971+ ) : (
972+ // The actions row takes the slot's place in the SAME render — a single
973+ // ~10px reflow instead of a collapse the buttons would ride upward or a
974+ // late mount the chase would visibly scroll to.
975+ actions && < div className = 'mt-2.5' > { actions } </ div >
990976 ) }
991977 </ div >
992978 )
0 commit comments