11/** @vitest -environment jsdom */
2- import { act , useState } from 'react'
2+ import { act , type ComponentProps , useState } from 'react'
33import { toast } from '@sim/emcn'
44import { createRoot , type Root } from 'react-dom/client'
55import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
@@ -10,9 +10,45 @@ const mocks = vi.hoisted(() => ({
1010 toggleListening : vi . fn ( ) ,
1111 resetTranscript : vi . fn ( ) ,
1212 submit : vi . fn ( ) ,
13+ contexts : vi . fn ( ) ,
1314 upload : vi . fn ( ) ,
1415} ) )
1516
17+ vi . mock ( '@/hooks/queries/workspace' , ( ) => ( {
18+ useWorkspacesQuery : ( ) => ( {
19+ data : [
20+ {
21+ id : 'workspace-a' ,
22+ name : 'Team' ,
23+ organizationId : 'organization-a' ,
24+ workspaceMode : 'grandfathered_shared' ,
25+ } ,
26+ {
27+ id : 'workspace-other' ,
28+ name : 'Other org' ,
29+ organizationId : 'organization-b' ,
30+ workspaceMode : 'organization' ,
31+ } ,
32+ ] ,
33+ } ) ,
34+ } ) )
35+ vi . mock ( '@/hooks/queries/skills' , ( ) => ( {
36+ useSkills : ( workspaceId : string ) => ( {
37+ data :
38+ workspaceId === 'workspace-a'
39+ ? [ { id : 'skill-a' , name : 'review' , description : 'Review a draft' } ]
40+ : [ ] ,
41+ } ) ,
42+ } ) )
43+ vi . mock ( '@/hooks/queries/mcp' , ( ) => ( { useMcpToolServers : ( ) => ( { data : [ ] } ) } ) )
44+ vi . mock ( '@/blocks/integration-matcher' , ( ) => ( {
45+ getIntegrationMatcher : ( ) => ( { regex : null , byName : new Map ( ) } ) ,
46+ } ) )
47+ vi . mock (
48+ '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown' ,
49+ ( ) => ( { PlusMenuDropdown : ( ) => null } )
50+ )
51+
1652vi . mock ( '@/hooks/use-speech-to-text' , ( ) => ( { useSpeechToText : mocks . speech } ) )
1753vi . mock ( '@/lib/uploads/client/session-upload' , ( ) => ( { uploadInternalFileSession : mocks . upload } ) )
1854vi . mock ( '@/hooks/use-animated-placeholder' , ( ) => ( { useAnimatedPlaceholder : ( ) => 'Ask Sim to' } ) )
@@ -30,6 +66,22 @@ let container: HTMLDivElement
3066beforeEach ( ( ) => {
3167 vi . clearAllMocks ( )
3268 vi . stubGlobal ( 'IS_REACT_ACT_ENVIRONMENT' , true )
69+ HTMLElement . prototype . scrollIntoView = vi . fn ( )
70+ vi . stubGlobal (
71+ 'DataTransfer' ,
72+ class {
73+ files : File [ ] = [ ]
74+ items = { add : ( file : File ) => this . files . push ( file ) }
75+ }
76+ )
77+ vi . stubGlobal (
78+ 'ResizeObserver' ,
79+ class {
80+ observe ( ) { }
81+ disconnect ( ) { }
82+ unobserve ( ) { }
83+ }
84+ )
3385 vi . stubGlobal (
3486 'URL' ,
3587 class extends URL {
@@ -71,7 +123,11 @@ afterEach(async () => {
71123async function render (
72124 isInitialView : boolean ,
73125 initialValue = 'Summarize' ,
74- requestMode : 'agent' | 'assistant' = 'assistant'
126+ requestMode : 'agent' | 'assistant' = 'assistant' ,
127+ controls : Pick <
128+ ComponentProps < typeof Composer > ,
129+ 'isSending' | 'showModeSelector' | 'onModeChange' | 'modeChangeDisabled' | 'restoredContexts'
130+ > = { isSending : false }
75131) {
76132 function Harness ( ) {
77133 const [ value , setValue ] = useState ( initialValue )
@@ -83,14 +139,19 @@ async function render(
83139 return (
84140 < Composer
85141 requestMode = { requestMode }
142+ showModeSelector = { controls . showModeSelector }
143+ onModeChange = { controls . onModeChange }
144+ modeChangeDisabled = { controls . modeChangeDisabled }
145+ restoredContexts = { controls . restoredContexts }
86146 value = { value }
87147 files = { files }
88148 onChange = { setValue }
89149 isInitialView = { isInitialView }
90- isSending = { false }
150+ isSending = { controls . isSending }
91151 onStop = { vi . fn ( ) }
92- onSubmit = { ( ) => {
93- mocks . submit ( value , files . attachedFiles )
152+ onSubmit = { ( text , contexts ) => {
153+ mocks . submit ( text , files . attachedFiles )
154+ mocks . contexts ( contexts )
94155 setValue ( '' )
95156 files . clearAttachedFiles ( )
96157 } }
@@ -106,6 +167,9 @@ describe('organization voice composer', () => {
106167 async ( isInitialView ) => {
107168 await render ( isInitialView )
108169 const mic = container . querySelector < HTMLButtonElement > ( 'button[aria-label="Voice input"]' ) !
170+ expect ( mic . previousElementSibling ?. getAttribute ( 'aria-label' ) ) . toBe (
171+ 'Model and reasoning effort'
172+ )
109173 expect ( mic . nextElementSibling ?. getAttribute ( 'aria-label' ) ) . toBe ( 'Send' )
110174 await act ( async ( ) => mic . click ( ) )
111175 expect ( mocks . toggleListening ) . toHaveBeenCalledOnce ( )
@@ -144,7 +208,14 @@ function fileList(files: File[]): FileList {
144208
145209async function paste ( files : File [ ] ) {
146210 const event = new Event ( 'paste' , { bubbles : true , cancelable : true } )
147- Object . defineProperty ( event , 'clipboardData' , { value : { files : fileList ( files ) } } )
211+ Object . defineProperty ( event , 'clipboardData' , {
212+ value : {
213+ files : fileList ( files ) ,
214+ items : files . map ( ( file ) => ( { kind : 'file' , getAsFile : ( ) => file } ) ) ,
215+ getData : ( ) => '' ,
216+ types : [ ] ,
217+ } ,
218+ } )
148219 await act ( async ( ) => container . querySelector ( 'textarea' ) ! . dispatchEvent ( event ) )
149220 return event
150221}
@@ -264,3 +335,161 @@ it('uploads an agent document with explicit mode while Assistant remains image-o
264335 expect ( container . querySelector ( 'input[type="file"]' ) ?. getAttribute ( 'accept' ) ) . toContain ( '.txt' )
265336 expect ( container . querySelector ( 'button[aria-label="Model and reasoning effort"]' ) ) . not . toBeNull ( )
266337} )
338+
339+ it ( 'discovers an organization-owned legacy workspace and sends its scoped skill context' , async ( ) => {
340+ await render ( true , '' , 'agent' )
341+ const slash = container . querySelector < HTMLButtonElement > ( 'button[aria-label="Skills"]' ) !
342+ expect ( slash . previousElementSibling ?. getAttribute ( 'aria-label' ) ) . toBe ( 'Attach file' )
343+ expect ( slash . previousElementSibling ?. previousElementSibling ?. getAttribute ( 'aria-label' ) ) . toBe (
344+ 'Add resources'
345+ )
346+ await act ( async ( ) =>
347+ slash . dispatchEvent ( new KeyboardEvent ( 'keydown' , { key : 'Enter' , bubbles : true } ) )
348+ )
349+ expect ( document . body . textContent ) . not . toContain ( 'Other org' )
350+ const workspace = [ ...document . querySelectorAll < HTMLElement > ( '[role="menuitem"]' ) ] . find (
351+ ( item ) => item . textContent === 'Team'
352+ ) !
353+ await act ( async ( ) => workspace . click ( ) )
354+ const skill = [ ...document . querySelectorAll < HTMLElement > ( 'button' ) ] . find (
355+ ( item ) => item . textContent === 'review'
356+ ) !
357+ expect ( skill ) . toBeDefined ( )
358+ await act ( async ( ) => skill . click ( ) )
359+ await act ( async ( ) =>
360+ container . querySelector < HTMLButtonElement > ( 'button[aria-label="Send"]' ) ! . click ( )
361+ )
362+ expect ( mocks . submit . mock . calls . at ( - 1 ) ?. [ 0 ] ) . toBe ( '/review ' )
363+ expect ( mocks . contexts ) . toHaveBeenLastCalledWith ( [
364+ { kind : 'skill' , skillId : 'skill-a' , label : 'review' , workspaceId : 'workspace-a' } ,
365+ ] )
366+ } )
367+
368+ it ( 'keeps restored queued skills scoped when replacing a draft' , async ( ) => {
369+ let restore : ( ) => void = ( ) => { }
370+ function Harness ( ) {
371+ const [ value , setValue ] = useState ( 'Original draft' )
372+ const [ contexts , setContexts ] = useState < ComponentProps < typeof Composer > [ 'restoredContexts' ] > ( )
373+ restore = ( ) => {
374+ setValue ( '/review fix this' )
375+ setContexts ( [
376+ { kind : 'skill' , skillId : 'skill-a' , label : 'review' , workspaceId : 'workspace-a' } ,
377+ ] )
378+ }
379+ const files = useFileAttachments ( {
380+ userId : 'user-a' ,
381+ organizationId : 'organization-a' ,
382+ requestMode : 'agent' ,
383+ } )
384+ return (
385+ < Composer
386+ requestMode = 'agent'
387+ value = { value }
388+ onChange = { setValue }
389+ restoredContexts = { contexts }
390+ files = { files }
391+ isInitialView = { false }
392+ isSending = { false }
393+ onStop = { ( ) => { } }
394+ onSubmit = { mocks . submit }
395+ />
396+ )
397+ }
398+ await act ( async ( ) => root . render ( < Harness /> ) )
399+ await act ( async ( ) => restore ( ) )
400+ expect ( container . querySelector ( 'textarea' ) ! . value ) . toBe ( '\u2003review fix this' )
401+ await act ( async ( ) =>
402+ container . querySelector < HTMLButtonElement > ( 'button[aria-label="Send"]' ) ! . click ( )
403+ )
404+ expect ( mocks . submit ) . toHaveBeenLastCalledWith ( '/review fix this' , [
405+ { kind : 'skill' , skillId : 'skill-a' , label : 'review' , workspaceId : 'workspace-a' } ,
406+ ] )
407+ } )
408+
409+ it ( 'does not expose resource or skill controls in Search' , async ( ) => {
410+ await render ( true )
411+ expect ( container . querySelector ( '[aria-label="Skills"]' ) ) . toBeNull ( )
412+ expect ( container . querySelector ( '[aria-label="Add resources"]' ) ) . toBeNull ( )
413+ expect ( container . querySelector ( '[aria-label="Attach images"]' ) ) . not . toBeNull ( )
414+ } )
415+
416+ it ( 'lists organization-owned legacy workspaces in the resource picker' , async ( ) => {
417+ await render ( true , '' , 'agent' )
418+ const plus = container . querySelector < HTMLButtonElement > ( 'button[aria-label="Add resources"]' ) !
419+ await act ( async ( ) =>
420+ plus . dispatchEvent ( new KeyboardEvent ( 'keydown' , { key : 'Enter' , bubbles : true } ) )
421+ )
422+ const items = [ ...document . querySelectorAll < HTMLElement > ( '[role="menuitem"]' ) ] . map (
423+ ( item ) => item . textContent
424+ )
425+ expect ( items ) . toContain ( 'Team' )
426+ expect ( items ) . not . toContain ( 'Other org' )
427+ expect ( items ) . not . toContain ( 'No accessible workspaces' )
428+ } )
429+
430+ it ( 'offers a text-only controlled mode picker after the staging input controls' , async ( ) => {
431+ const onModeChange = vi . fn ( )
432+ await render ( true , 'Preserved draft' , 'agent' , {
433+ isSending : false ,
434+ showModeSelector : true ,
435+ onModeChange,
436+ } )
437+ const mode = container . querySelector < HTMLButtonElement > ( '[aria-label="Conversation mode"]' ) !
438+ expect ( mode . previousElementSibling ?. getAttribute ( 'aria-label' ) ) . toBe ( 'Skills' )
439+ expect ( mode . querySelector ( 'svg' ) ) . toBeNull ( )
440+ await act ( async ( ) =>
441+ mode . dispatchEvent ( new KeyboardEvent ( 'keydown' , { key : 'Enter' , bubbles : true } ) )
442+ )
443+ const search = [ ...document . querySelectorAll < HTMLElement > ( '[role="menuitemradio"]' ) ] . find (
444+ ( item ) => item . textContent === 'Search'
445+ ) !
446+ expect ( search . querySelector ( 'svg' ) ) . toBeNull ( )
447+ await act ( async ( ) => search . click ( ) )
448+ expect ( onModeChange ) . toHaveBeenCalledExactlyOnceWith ( 'assistant' )
449+ expect ( container . querySelector ( 'textarea' ) ! . value ) . toBe ( 'Preserved draft' )
450+ } )
451+
452+ it ( 'disables the mode picker when the parent has queued messages' , async ( ) => {
453+ await render ( true , '' , 'agent' , {
454+ isSending : false ,
455+ showModeSelector : true ,
456+ onModeChange : vi . fn ( ) ,
457+ modeChangeDisabled : true ,
458+ } )
459+ expect (
460+ container . querySelector < HTMLButtonElement > ( '[aria-label="Conversation mode"]' ) ! . disabled
461+ ) . toBe ( true )
462+ } )
463+
464+ it . each ( [ 'skill' , 'file' ] as const ) (
465+ 'keeps a Build draft intact when %s context cannot move to Search' ,
466+ async ( kind ) => {
467+ const onModeChange = vi . fn ( )
468+ const info = vi . spyOn ( toast , 'info' ) . mockReturnValue ( 'notice' )
469+ await render ( true , kind === 'skill' ? '/review Draft' : 'Document draft' , 'agent' , {
470+ isSending : false ,
471+ showModeSelector : true ,
472+ onModeChange,
473+ restoredContexts :
474+ kind === 'skill'
475+ ? [ { kind : 'skill' , skillId : 'skill-a' , label : 'review' , workspaceId : 'workspace-a' } ]
476+ : undefined ,
477+ } )
478+ if ( kind === 'file' )
479+ await paste ( [ new File ( [ 'document' ] , 'note.pdf' , { type : 'application/pdf' } ) ] )
480+ const before = container . querySelector ( 'textarea' ) ! . value
481+ const mode = container . querySelector < HTMLButtonElement > ( '[aria-label="Conversation mode"]' ) !
482+ await act ( async ( ) =>
483+ mode . dispatchEvent ( new KeyboardEvent ( 'keydown' , { key : 'Enter' , bubbles : true } ) )
484+ )
485+ const search = [ ...document . querySelectorAll < HTMLElement > ( '[role="menuitemradio"]' ) ] . find (
486+ ( item ) => item . textContent === 'Search'
487+ ) !
488+ await act ( async ( ) => search . click ( ) )
489+ expect ( onModeChange ) . not . toHaveBeenCalled ( )
490+ expect ( info ) . toHaveBeenCalledWith (
491+ 'Remove resource and skill mentions and non-image attachments before switching to Search.'
492+ )
493+ expect ( container . querySelector ( 'textarea' ) ! . value ) . toBe ( before )
494+ }
495+ )
0 commit comments