@@ -422,6 +422,183 @@ describe('useAutoSave — debounced autosave', () => {
422422 } ) ;
423423} ) ;
424424
425+ describe ( 'useAutoSave — typing as a run finishes' , ( ) => {
426+ /** Real storage for these, because the loss is in what the record holds at the moment the key
427+ * changes: a mock that answers every read with the same string cannot express it. */
428+ const actualUtils = jest . requireActual ( '~/utils' ) ;
429+
430+ const getInputListener = ( textAreaRef : React . RefObject < HTMLTextAreaElement > ) =>
431+ ( textAreaRef . current ! . addEventListener as unknown as jest . Mock ) . mock . calls . find (
432+ ( [ event ] ) => event === 'input' ,
433+ ) ! [ 1 ] as ( e : unknown ) => void ;
434+
435+ /** Types into the composer the way the browser does: the value is already there when the event
436+ * fires, so a debounced write reads it whether or not the event carried it. */
437+ const type = ( textAreaRef : React . RefObject < HTMLTextAreaElement > , value : string ) => {
438+ textAreaRef . current ! . value = value ;
439+ getInputListener ( textAreaRef ) ( { target : { value } } ) ;
440+ } ;
441+
442+ beforeEach ( ( ) => {
443+ jest . useFakeTimers ( ) ;
444+ mockGetDraft . mockImplementation ( actualUtils . getDraft ) ;
445+ mockSetDraft . mockImplementation ( actualUtils . setDraft ) ;
446+ } ) ;
447+
448+ afterEach ( ( ) => {
449+ jest . useRealTimers ( ) ;
450+ /** Hand the rest of the suite its stubs back. An implementation set here outlives the block,
451+ * and a real `setDraft` leaking into a later test writes records that test never asked for. */
452+ mockGetDraft . mockReset ( ) ;
453+ mockSetDraft . mockReset ( ) ;
454+ } ) ;
455+
456+ /** The reported bug. The composer is keyed under PENDING while a run streams and under the
457+ * conversation once it ends, and the key change tore off whatever the 25ms debounce had not
458+ * written yet: the pending record still held the previous flush, run end migrated that record,
459+ * and the restore put it back over the composer. Every keystroke since the last flush was
460+ * silently rolled back mid-sentence. */
461+ it ( 'keeps the keystrokes the debounce had not written when the run ends' , ( ) => {
462+ const textAreaRef = makeTextAreaRef ( ) ;
463+ const { rerender } = renderHook (
464+ ( { isSubmitting } : { isSubmitting : boolean } ) =>
465+ useAutoSave ( {
466+ isSubmitting,
467+ conversationId : 'convo-1' ,
468+ textAreaRef,
469+ files : new Map ( ) ,
470+ setFiles : jest . fn ( ) ,
471+ } ) ,
472+ { initialProps : { isSubmitting : true } } ,
473+ ) ;
474+
475+ act ( ( ) => {
476+ type ( textAreaRef , 'my follow up' ) ;
477+ jest . advanceTimersByTime ( 50 ) ;
478+ } ) ;
479+
480+ /** Still typing when the response lands, inside the debounce window. */
481+ act ( ( ) => {
482+ type ( textAreaRef , 'my follow up question' ) ;
483+ rerender ( { isSubmitting : false } ) ;
484+ } ) ;
485+
486+ expect ( mockSetValue ) . toHaveBeenLastCalledWith ( 'text' , 'my follow up question' ) ;
487+ expect ( actualUtils . getDraft ( 'convo-1' ) ) . toBe ( 'my follow up question' ) ;
488+ } ) ;
489+
490+ it ( 'keeps the whole message when the run ends before anything was written' , ( ) => {
491+ const textAreaRef = makeTextAreaRef ( ) ;
492+ const { rerender } = renderHook (
493+ ( { isSubmitting } : { isSubmitting : boolean } ) =>
494+ useAutoSave ( {
495+ isSubmitting,
496+ conversationId : 'convo-1' ,
497+ textAreaRef,
498+ files : new Map ( ) ,
499+ setFiles : jest . fn ( ) ,
500+ } ) ,
501+ { initialProps : { isSubmitting : true } } ,
502+ ) ;
503+
504+ act ( ( ) => {
505+ type ( textAreaRef , 'a whole sentence typed quickly' ) ;
506+ rerender ( { isSubmitting : false } ) ;
507+ } ) ;
508+
509+ expect ( mockSetValue ) . toHaveBeenLastCalledWith ( 'text' , 'a whole sentence typed quickly' ) ;
510+ } ) ;
511+
512+ /** A draft of one character is deliberately not persisted, so the record cannot speak for the
513+ * composer here. The composer still can, and it is what the user is looking at. */
514+ it ( 'keeps a single character typed as the run ends' , ( ) => {
515+ const textAreaRef = makeTextAreaRef ( ) ;
516+ const { rerender } = renderHook (
517+ ( { isSubmitting } : { isSubmitting : boolean } ) =>
518+ useAutoSave ( {
519+ isSubmitting,
520+ conversationId : 'convo-1' ,
521+ textAreaRef,
522+ files : new Map ( ) ,
523+ setFiles : jest . fn ( ) ,
524+ } ) ,
525+ { initialProps : { isSubmitting : true } } ,
526+ ) ;
527+
528+ act ( ( ) => {
529+ type ( textAreaRef , 'k' ) ;
530+ rerender ( { isSubmitting : false } ) ;
531+ } ) ;
532+
533+ expect ( mockSetValue ) . toHaveBeenLastCalledWith ( 'text' , 'k' ) ;
534+ } ) ;
535+
536+ /** The same key change in the other direction. `useSubmitMessage` asks and then resets the form
537+ * in one handler, so the composer is already empty when the render that flips to PENDING lands:
538+ * the write flushed on the way out records the emptiness. Were it to record the sent text, run
539+ * end would migrate it straight back into the composer as a duplicate of the message. */
540+ it ( 'does not keep the sent text as a draft when submitting mid-keystroke' , ( ) => {
541+ const textAreaRef = makeTextAreaRef ( ) ;
542+ const { rerender } = renderHook (
543+ ( { isSubmitting } : { isSubmitting : boolean } ) =>
544+ useAutoSave ( {
545+ isSubmitting,
546+ conversationId : 'convo-1' ,
547+ textAreaRef,
548+ files : new Map ( ) ,
549+ setFiles : jest . fn ( ) ,
550+ } ) ,
551+ { initialProps : { isSubmitting : false } } ,
552+ ) ;
553+
554+ act ( ( ) => {
555+ type ( textAreaRef , 'sent message' ) ;
556+ } ) ;
557+
558+ /** Submit: `ask` then `methods.reset()`, batched into the render that starts the run. */
559+ act ( ( ) => {
560+ textAreaRef . current ! . value = '' ;
561+ rerender ( { isSubmitting : true } ) ;
562+ } ) ;
563+
564+ expect ( actualUtils . getDraft ( 'convo-1' ) ) . toBe ( '' ) ;
565+ expect ( actualUtils . getDraft ( Constants . PENDING_CONVO ) ) . toBe ( '' ) ;
566+ } ) ;
567+
568+ /** The other side of the same key change, and the reason the in-flight write was dropped rather
569+ * than flushed: a steer consumes the composer and clears it programmatically, and run end must
570+ * not put the just-sent text back. An empty composer has nothing to defend, so the record wins. */
571+ it ( 'does not resurrect text a steer consumed as the run ended' , ( ) => {
572+ const textAreaRef = makeTextAreaRef ( ) ;
573+ const { rerender } = renderHook (
574+ ( { isSubmitting } : { isSubmitting : boolean } ) =>
575+ useAutoSave ( {
576+ isSubmitting,
577+ conversationId : 'convo-1' ,
578+ textAreaRef,
579+ files : new Map ( ) ,
580+ setFiles : jest . fn ( ) ,
581+ } ) ,
582+ { initialProps : { isSubmitting : true } } ,
583+ ) ;
584+
585+ act ( ( ) => {
586+ type ( textAreaRef , 'steered message' ) ;
587+ jest . advanceTimersByTime ( 50 ) ;
588+ } ) ;
589+
590+ /** The steer took the text and emptied the composer, and dropped the pending draft with it. */
591+ act ( ( ) => {
592+ actualUtils . clearAllDrafts ( Constants . PENDING_CONVO ) ;
593+ type ( textAreaRef , '' ) ;
594+ rerender ( { isSubmitting : false } ) ;
595+ } ) ;
596+
597+ expect ( mockSetValue ) . toHaveBeenLastCalledWith ( 'text' , '' ) ;
598+ expect ( actualUtils . getDraft ( 'convo-1' ) ) . toBe ( '' ) ;
599+ } ) ;
600+ } ) ;
601+
425602describe ( 'useAutoSave — side-by-side pending drafts' , ( ) => {
426603 const pane0PendingId = Constants . PENDING_CONVO ;
427604 const pane1PendingId = `${ Constants . PENDING_CONVO } :1` ;
0 commit comments