@@ -56,6 +56,12 @@ vi.mock('react-i18next', () => ({
5656 if ( key === 'session.send_placeholder_desktop_upload' ) {
5757 return `${ String ( opts ?. placeholder ?? '' ) } Supports fast multi-file paste upload` ;
5858 }
59+ if ( key === 'upload.long_text_attached' ) {
60+ return `Large pasted text attached as ${ String ( opts ?. name ?? '' ) } ` ;
61+ }
62+ if ( key === 'upload.long_text_requires_attachment' ) {
63+ return 'Paste is too large for inline input here. Upload it as a file instead.' ;
64+ }
5965 if ( key === 'session.stop_plain' ) return 'Stop' ;
6066 if ( key === 'session.supervision.quickLabel' ) return 'Auto' ;
6167 if ( key === 'session.supervision.quickTitle' ) return 'Auto mode' ;
@@ -139,6 +145,7 @@ vi.mock('../../src/components/AtPicker.js', () => ({
139145} ) ) ;
140146
141147const uploadFileMock = vi . fn ( ) ;
148+ const execCommandMock = vi . fn ( ( ) => true ) ;
142149const getUserPrefMock = vi . fn ( ) . mockResolvedValue ( null ) ;
143150const saveUserPrefMock = vi . fn ( ) . mockResolvedValue ( undefined ) ;
144151const fetchSupervisorDefaultsMock = vi . fn ( ) . mockResolvedValue ( null ) ;
@@ -275,6 +282,17 @@ afterEach(() => {
275282
276283 beforeEach ( ( ) => {
277284 vi . clearAllMocks ( ) ;
285+ execCommandMock . mockImplementation ( ( _command : string , _ui ?: boolean , value ?: string ) => {
286+ const active = document . activeElement as HTMLDivElement | null ;
287+ if ( active && typeof active . textContent === 'string' ) {
288+ active . textContent = `${ active . textContent } ${ String ( value ?? '' ) } ` ;
289+ }
290+ return true ;
291+ } ) ;
292+ Object . defineProperty ( document , 'execCommand' , {
293+ configurable : true ,
294+ value : execCommandMock ,
295+ } ) ;
278296 sessionStorage . clear ( ) ;
279297 localStorage . clear ( ) ;
280298 fetchSupervisorDefaultsMock . mockResolvedValue ( null ) ;
@@ -2407,6 +2425,91 @@ afterEach(() => {
24072425 expect ( document . querySelector ( '.controls-input' ) ?. getAttribute ( 'data-placeholder' ) ) . toBe ( 'Send to my-project…' ) ;
24082426 } ) ;
24092427
2428+ it ( 'keeps normal plain-text paste inline for short clipboard content' , ( ) => {
2429+ render (
2430+ < SessionControls
2431+ ws = { makeWs ( ) as any }
2432+ activeSession = { makeSession ( ) }
2433+ quickData = { makeQuickData ( ) as any }
2434+ serverId = "srv-1"
2435+ /> ,
2436+ ) ;
2437+
2438+ const input = screen . getByRole ( 'textbox' ) as HTMLDivElement ;
2439+ input . focus ( ) ;
2440+ fireEvent . paste ( input , {
2441+ clipboardData : {
2442+ getData : ( type : string ) => type === 'text/plain' ? 'short inline paste' : '' ,
2443+ } ,
2444+ } ) ;
2445+
2446+ expect ( execCommandMock ) . toHaveBeenCalledWith ( 'insertText' , false , 'short inline paste' ) ;
2447+ expect ( input . textContent ) . toBe ( 'short inline paste' ) ;
2448+ expect ( uploadFileMock ) . not . toHaveBeenCalled ( ) ;
2449+ } ) ;
2450+
2451+ it ( 'converts oversized plain-text paste into an attachment upload' , async ( ) => {
2452+ uploadFileMock . mockResolvedValue ( { attachment : { daemonPath : '/tmp/pasted-text.txt' } } ) ;
2453+ const ws = makeWs ( ) ;
2454+ render (
2455+ < SessionControls
2456+ ws = { ws as any }
2457+ activeSession = { makeSession ( { name : 'my-session' } ) }
2458+ quickData = { makeQuickData ( ) as any }
2459+ serverId = "srv-1"
2460+ /> ,
2461+ ) ;
2462+
2463+ const input = screen . getByRole ( 'textbox' ) as HTMLDivElement ;
2464+ input . focus ( ) ;
2465+ const longText = 'x' . repeat ( 13000 ) ;
2466+ fireEvent . paste ( input , {
2467+ clipboardData : {
2468+ getData : ( type : string ) => type === 'text/plain' ? longText : '' ,
2469+ } ,
2470+ } ) ;
2471+
2472+ await waitFor ( ( ) => expect ( uploadFileMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
2473+ const uploadedFile = uploadFileMock . mock . calls [ 0 ] ?. [ 1 ] as File ;
2474+ expect ( uploadedFile ) . toBeInstanceOf ( File ) ;
2475+ expect ( uploadedFile . name ) . toMatch ( / ^ p a s t e d - t e x t - .* \. t x t $ / ) ;
2476+ expect ( await uploadedFile . text ( ) ) . toBe ( longText ) ;
2477+ expect ( execCommandMock ) . not . toHaveBeenCalled ( ) ;
2478+ expect ( input . textContent ) . toBe ( '' ) ;
2479+ await waitFor ( ( ) => {
2480+ expect ( document . querySelector ( '.attachment-badge-name' ) ?. textContent ) . toMatch ( / ^ p a s t e d - t e x t - .* \. t x t $ / ) ;
2481+ } ) ;
2482+
2483+ fireEvent . click ( screen . getByRole ( 'button' , { name : / s e n d / i } ) ) ;
2484+ expectSendPayload ( ws , {
2485+ sessionName : 'my-session' ,
2486+ text : '@/tmp/pasted-text.txt' ,
2487+ } ) ;
2488+ } ) ;
2489+
2490+ it ( 'blocks oversized plain-text paste when upload context is unavailable' , async ( ) => {
2491+ render (
2492+ < SessionControls
2493+ ws = { makeWs ( ) as any }
2494+ activeSession = { makeSession ( ) }
2495+ quickData = { makeQuickData ( ) as any }
2496+ /> ,
2497+ ) ;
2498+
2499+ const input = screen . getByRole ( 'textbox' ) as HTMLDivElement ;
2500+ input . focus ( ) ;
2501+ fireEvent . paste ( input , {
2502+ clipboardData : {
2503+ getData : ( type : string ) => type === 'text/plain' ? 'y' . repeat ( 13000 ) : '' ,
2504+ } ,
2505+ } ) ;
2506+
2507+ expect ( uploadFileMock ) . not . toHaveBeenCalled ( ) ;
2508+ expect ( execCommandMock ) . not . toHaveBeenCalled ( ) ;
2509+ expect ( input . textContent ) . toBe ( '' ) ;
2510+ expect ( await screen . findByText ( 'Paste is too large for inline input here. Upload it as a file instead.' ) ) . toBeDefined ( ) ;
2511+ } ) ;
2512+
24102513 // TODO: fix — file upload mock doesn't trigger state update in jsdom
24112514 describe . skip ( 'attachment badges' , ( ) => {
24122515 it ( 'shows badge after file upload' , async ( ) => {
0 commit comments