@@ -138,19 +138,38 @@ describeWithJsdom('RichTextInput external sync', () => {
138138
139139 function paste (
140140 editor : HTMLDivElement ,
141- options : { items ?: Array < { kind : string ; type : string ; getAsFile : ( ) => File | null } > ; types ?: string [ ] ; text ?: string } ,
141+ options : {
142+ items ?: Array < { kind : string ; type : string ; getAsFile : ( ) => File | null } > ;
143+ types ?: string [ ] ;
144+ text ?: string ;
145+ html ?: string ;
146+ } ,
142147 ) {
143148 const event = new window . Event ( 'paste' , { bubbles : true , cancelable : true } ) ;
144149 Object . defineProperty ( event , 'clipboardData' , {
145150 value : {
146151 items : options . items ?? [ ] ,
147152 types : options . types ?? [ ] ,
148- getData : ( type : string ) => type === 'text/plain' ? options . text ?? '' : '' ,
153+ getData : ( type : string ) => {
154+ if ( type === 'text/plain' ) return options . text ?? '' ;
155+ if ( type === 'text/html' ) return options . html ?? '' ;
156+ return '' ;
157+ } ,
149158 } ,
150159 } ) ;
151160 editor . dispatchEvent ( event ) ;
152161 }
153162
163+ function copy ( editor : HTMLDivElement ) {
164+ const clipboard = new Map < string , string > ( ) ;
165+ const event = new window . Event ( 'copy' , { bubbles : true , cancelable : true } ) ;
166+ Object . defineProperty ( event , 'clipboardData' , {
167+ value : { setData : ( type : string , value : string ) => clipboard . set ( type , value ) } ,
168+ } ) ;
169+ editor . dispatchEvent ( event ) ;
170+ return clipboard ;
171+ }
172+
154173 function setCaret ( editor : HTMLDivElement , offset : number ) {
155174 const selection = window . getSelection ( ) ;
156175 const range = document . createRange ( ) ;
@@ -263,6 +282,89 @@ describeWithJsdom('RichTextInput external sync', () => {
263282 expect ( editor . querySelector ( '[data-large-paste-placeholder]' ) ) . toBeTruthy ( ) ;
264283 } ) ;
265284
285+ it ( 'rebuilds capsules from pasted inline token text' , async ( ) => {
286+ const onChange = vi . fn ( ) ;
287+ await act ( async ( ) => {
288+ root . render (
289+ < RichTextInput
290+ value = ""
291+ onChange = { onChange }
292+ contexts = { emptyContexts }
293+ onRemoveContext = { ( ) => { } }
294+ /> ,
295+ ) ;
296+ } ) ;
297+ const editor = container . querySelector ( '.rich-text-input' ) as HTMLDivElement ;
298+ setCaret ( editor , 0 ) ;
299+
300+ paste ( editor , { types : [ 'text/plain' ] , text : 'run [$pdf] and [$doc] please' } ) ;
301+
302+ const pills = Array . from (
303+ editor . querySelectorAll < HTMLElement > ( '[data-inline-token-type="skill-ref"]' ) ,
304+ ) ;
305+ expect ( pills . map ( pill => pill . dataset . tagFormat ) ) . toEqual ( [ '[$pdf]' , '[$doc]' ] ) ;
306+ expect ( editor . textContent ) . toBe ( 'run pdf× and doc× please' ) ;
307+ expect ( onChange ) . toHaveBeenLastCalledWith ( 'run [$pdf] and [$doc] please' , emptyContexts ) ;
308+ } ) ;
309+
310+ it ( 'restores capsules from the composer clipboard payload of a copied message' , async ( ) => {
311+ const onChange = vi . fn ( ) ;
312+ await act ( async ( ) => {
313+ root . render (
314+ < RichTextInput
315+ value = ""
316+ onChange = { onChange }
317+ contexts = { emptyContexts }
318+ onRemoveContext = { ( ) => { } }
319+ /> ,
320+ ) ;
321+ } ) ;
322+ const editor = container . querySelector ( '.rich-text-input' ) as HTMLDivElement ;
323+ setCaret ( editor , 0 ) ;
324+
325+ paste ( editor , {
326+ types : [ 'text/plain' , 'text/html' ] ,
327+ text : '[Skill: pdf] summarize it' ,
328+ html : '<div data-openbitfun-composer-clipboard="1" '
329+ + 'data-openbitfun-composer-clipboard-tokens="[$pdf] summarize it">'
330+ + '[Skill: pdf] summarize it</div>' ,
331+ } ) ;
332+
333+ expect ( editor . querySelector < HTMLElement > ( '[data-inline-token-type="skill-ref"]' ) ?. dataset . tagFormat )
334+ . toBe ( '[$pdf]' ) ;
335+ expect ( onChange ) . toHaveBeenLastCalledWith ( '[$pdf] summarize it' , emptyContexts ) ;
336+ } ) ;
337+
338+ it ( 'copies a selection as composer token text with a marked payload' , async ( ) => {
339+ const inputRef = createRef < RichTextInputElement > ( ) ;
340+ await act ( async ( ) => {
341+ root . render (
342+ < RichTextInput
343+ ref = { inputRef }
344+ value = "compare [$pdf] with [$doc]"
345+ onChange = { ( ) => { } }
346+ contexts = { emptyContexts }
347+ onRemoveContext = { ( ) => { } }
348+ /> ,
349+ ) ;
350+ } ) ;
351+ const editor = inputRef . current ! ;
352+
353+ const selection = window . getSelection ( ) ! ;
354+ const range = document . createRange ( ) ;
355+ range . selectNodeContents ( editor ) ;
356+ selection . removeAllRanges ( ) ;
357+ selection . addRange ( range ) ;
358+
359+ const clipboard = copy ( editor ) ;
360+
361+ expect ( clipboard . get ( 'text/plain' ) ) . toBe ( 'compare [$pdf] with [$doc]' ) ;
362+ const html = clipboard . get ( 'text/html' ) ?? '' ;
363+ expect ( html ) . toContain ( 'data-openbitfun-composer-clipboard="1"' ) ;
364+ expect ( html ) . toContain ( 'data-openbitfun-composer-clipboard-tokens="compare [$pdf] with [$doc]"' ) ;
365+ expect ( html ) . not . toContain ( 'rich-text-tag-pill__remove' ) ;
366+ } ) ;
367+
266368 it ( 'keeps the existing DOM node when parent echoes local input' , async ( ) => {
267369 const harnessRef = createRef < HarnessHandle > ( ) ;
268370 const editor = await renderHarness ( harnessRef ) ;
0 commit comments