44import {
55 act ,
66 type ButtonHTMLAttributes ,
7+ cloneElement ,
78 type InputHTMLAttributes ,
9+ type ReactElement ,
810 type ReactNode ,
911 type Ref ,
1012} from 'react'
@@ -21,6 +23,9 @@ const {
2123 mockHandleRunWorkflow,
2224 mockReadSSEEvents,
2325 mockRevokeObjectURL,
26+ mockTooltipPointerEnter,
27+ executionState,
28+ registryState,
2429 WorkflowAttachmentUploadErrorMock,
2530} = vi . hoisted ( ( ) => {
2631 class WorkflowAttachmentUploadErrorMock extends Error {
@@ -61,16 +66,14 @@ const {
6166 mockHandleRunWorkflow : vi . fn ( ) ,
6267 mockReadSSEEvents : vi . fn ( ) ,
6368 mockRevokeObjectURL : vi . fn ( ) ,
69+ mockTooltipPointerEnter : vi . fn ( ) ,
70+ executionState : { isExecuting : false } ,
71+ registryState : { activeWorkflowId : 'workflow-1' } ,
6472 WorkflowAttachmentUploadErrorMock,
6573 }
6674} )
6775
6876vi . mock ( '@sim/emcn' , ( ) => ( {
69- Badge : ( {
70- children,
71- className : _className ,
72- ...props
73- } : ButtonHTMLAttributes < HTMLButtonElement > ) => < button { ...props } > { children } </ button > ,
7477 Button : ( {
7578 children,
7679 className : _className ,
@@ -104,7 +107,10 @@ vi.mock('@sim/emcn', () => ({
104107 PopoverTrigger : ( { children } : { children : ReactNode } ) => < div > { children } </ div > ,
105108 Tooltip : {
106109 Root : ( { children } : { children : ReactNode } ) => < div > { children } </ div > ,
107- Trigger : ( { children } : { children : ReactNode } ) => < div > { children } </ div > ,
110+ Trigger : ( { children } : { children : ReactElement } ) =>
111+ cloneElement ( children as ReactElement < { onPointerEnter ?: ( ) => void } > , {
112+ onPointerEnter : mockTooltipPointerEnter ,
113+ } ) ,
108114 Content : ( { children } : { children : ReactNode } ) => < div > { children } </ div > ,
109115 } ,
110116 Trash : ( ) => < span data-icon = 'Trash' /> ,
@@ -199,7 +205,7 @@ vi.mock('@/stores/chat/store', () => ({
199205} ) )
200206
201207vi . mock ( '@/stores/execution' , ( ) => ( {
202- useIsCurrentWorkflowExecuting : ( ) => false ,
208+ useIsCurrentWorkflowExecuting : ( ) => executionState . isExecuting ,
203209} ) )
204210
205211vi . mock ( '@/stores/operation-queue/store' , ( ) => ( {
@@ -214,7 +220,7 @@ vi.mock('@/stores/terminal', () => ({
214220
215221vi . mock ( '@/stores/workflows/registry/store' , ( ) => ( {
216222 useWorkflowRegistry : ( selector : ( state : { activeWorkflowId : string } ) => unknown ) =>
217- selector ( { activeWorkflowId : 'workflow-1' } ) ,
223+ selector ( registryState ) ,
218224} ) )
219225
220226vi . mock ( '@/stores/workflows/subblock/store' , ( ) => ( {
@@ -234,6 +240,7 @@ vi.mock('@/stores/chat/utils', () => ({
234240} ) )
235241
236242import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat'
243+ import { MAX_CHAT_FILES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks'
237244
238245let container : HTMLDivElement
239246let root : Root
@@ -280,6 +287,8 @@ describe('floating chat attachment uploads', () => {
280287 beforeEach ( async ( ) => {
281288 vi . useFakeTimers ( )
282289 vi . clearAllMocks ( )
290+ executionState . isExecuting = false
291+ registryState . activeWorkflowId = 'workflow-1'
283292 mockCreateObjectURL . mockReturnValue ( 'blob:diagram-preview' )
284293 mockReadSSEEvents . mockResolvedValue ( undefined )
285294 vi . stubGlobal ( 'FileReader' , mockFileReader )
@@ -308,6 +317,70 @@ describe('floating chat attachment uploads', () => {
308317 vi . unstubAllGlobals ( )
309318 } )
310319
320+ it ( 'opens file selection through a named native action' , ( ) => {
321+ const action = container . querySelector < HTMLButtonElement > ( 'button[aria-label="Attach file"]' )
322+ const fileInput = container . querySelector < HTMLInputElement > ( '#floating-chat-file-input' )
323+ expect ( action ?. type ) . toBe ( 'button' )
324+ expect ( action ?. disabled ) . toBe ( false )
325+ expect ( action ?. querySelector ( '[data-icon="Paperclip"]' ) ) . not . toBeNull ( )
326+ expect ( fileInput ) . not . toBeNull ( )
327+
328+ const openPicker = vi . fn ( )
329+ if ( fileInput ) fileInput . click = openPicker
330+ act ( ( ) => action ?. click ( ) )
331+ expect ( openPicker ) . toHaveBeenCalledTimes ( 1 )
332+ } )
333+
334+ it . each ( [ 'executing' , 'no workflow' ] as const ) ( 'prevents file selection with %s' , ( condition ) => {
335+ executionState . isExecuting = condition === 'executing'
336+ registryState . activeWorkflowId = condition === 'no workflow' ? '' : 'workflow-1'
337+ act ( ( ) => root . render ( < Chat /> ) )
338+
339+ const action = container . querySelector < HTMLButtonElement > ( 'button[aria-label="Attach file"]' )
340+ const fileInput = container . querySelector < HTMLInputElement > ( '#floating-chat-file-input' )
341+ expect ( action ?. disabled ) . toBe ( true )
342+ expect ( fileInput ?. disabled ) . toBe ( true )
343+
344+ const openPicker = vi . fn ( )
345+ if ( fileInput ) fileInput . click = openPicker
346+ act ( ( ) => action ?. click ( ) )
347+ expect ( openPicker ) . not . toHaveBeenCalled ( )
348+ } )
349+
350+ it ( 'keeps the tooltip hover target available when attachment is disabled' , ( ) => {
351+ executionState . isExecuting = true
352+ act ( ( ) => root . render ( < Chat /> ) )
353+
354+ const action = container . querySelector < HTMLButtonElement > ( 'button[aria-label="Attach file"]' )
355+ const trigger = action ?. parentElement
356+ expect ( action ?. disabled ) . toBe ( true )
357+ expect ( trigger ?. tagName ) . toBe ( 'SPAN' )
358+ expect ( trigger ?. hasAttribute ( 'disabled' ) ) . toBe ( false )
359+
360+ act ( ( ) => trigger ?. dispatchEvent ( new Event ( 'pointerover' , { bubbles : true } ) ) )
361+ expect ( mockTooltipPointerEnter ) . toHaveBeenCalledTimes ( 1 )
362+ } )
363+
364+ it ( 'disables attachment selection at the file limit' , ( ) => {
365+ const fileInput = container . querySelector < HTMLInputElement > ( '#floating-chat-file-input' )
366+ if ( ! fileInput ) throw new Error ( 'Expected file input' )
367+ Object . defineProperty ( fileInput , 'files' , {
368+ configurable : true ,
369+ value : Array . from (
370+ { length : MAX_CHAT_FILES } ,
371+ ( _ , index ) => new File ( [ 'x' ] , `file-${ index } .txt` )
372+ ) ,
373+ } )
374+ act ( ( ) => fileInput . dispatchEvent ( new Event ( 'change' , { bubbles : true } ) ) )
375+
376+ const action = container . querySelector < HTMLButtonElement > ( 'button[aria-label="Attach file"]' )
377+ expect ( action ?. disabled ) . toBe ( true )
378+ const openPicker = vi . fn ( )
379+ fileInput . click = openPicker
380+ act ( ( ) => action ?. click ( ) )
381+ expect ( openPicker ) . not . toHaveBeenCalled ( )
382+ } )
383+
311384 it ( 'uses uploaded URLs for message previews without base64 conversion' , async ( ) => {
312385 const file = new File ( [ 'diagram' ] , 'diagram.png' , { type : 'image/png' } )
313386 mockHandleRunWorkflow . mockResolvedValue ( {
0 commit comments