@@ -196,8 +196,9 @@ test("keeps a file diff anchored while it expands and collapses", async ({ page
196196 timelineDetail : { ...timelinePresets [ 2 ] . value , edit : { placement : "separate" , details : "collapsed" } } ,
197197 } ,
198198 messages : [
199- userMessage ( [ userText ( "Preceding context " . repeat ( 120 ) ) ] ) ,
199+ userMessage ( ) ,
200200 assistantMessage ( [
201+ textPart ( "prt_file_projection_preceding" , "Preceding context " . repeat ( 240 ) ) ,
201202 toolPart (
202203 id ,
203204 "patch" ,
@@ -754,3 +755,168 @@ test("groups consecutive sent prompts under the last one's meta line, with each
754755 await expect ( first . locator ( '[data-slot="user-message-run-actions"]' ) ) . toHaveCSS ( "opacity" , "1" )
755756 await expect ( first . getByRole ( "button" , { name : "Copy message" } ) ) . toHaveCount ( 1 )
756757} )
758+
759+ test ( "leaves a long prompt unfolded while Fold long messages is off" , async ( { page } ) => {
760+ const text = Array . from ( { length : 20 } , ( _ , index ) => `Long line ${ index + 1 } ` ) . join ( "\n" )
761+
762+ await setupTimeline ( page , {
763+ messages : [
764+ userMessage ( [ userText ( text , { id : "prt_msg_0001_nofold_text" } ) ] , {
765+ id : "msg_0001_nofold" ,
766+ created : 1700000000000 ,
767+ } ) ,
768+ ] ,
769+ viewport : { width : 1200 , height : 700 } ,
770+ } )
771+
772+ const row = page . locator ( '[data-timeline-row="UserMessage"][data-message-id="msg_0001_nofold"]' )
773+ await expect ( row ) . toContainText ( "Long line 20" )
774+ await expect ( row . locator ( '[data-slot="user-message-fold"]' ) ) . toHaveCount ( 0 )
775+ await expect ( row . getByRole ( "button" , { name : "Read more" } ) ) . toHaveCount ( 0 )
776+ } )
777+
778+ test ( "folds a prompt past 12 lines to its first 8 and keeps it open after scrolling out of view" , async ( { page } ) => {
779+ const lines = ( name : string , count : number ) =>
780+ Array . from ( { length : count } , ( _ , index ) => `${ name } line ${ index + 1 } ` ) . join ( "\n" )
781+
782+ const prompt = ( id : string , text : string , created : number ) =>
783+ userMessage ( [ userText ( text , { id : `prt_${ id } _text` } ) ] , { id, created } )
784+
785+ const reply = ( id : string , parentID : string , created : number ) =>
786+ assistantMessage (
787+ [ { id : `prt_${ id } _text` , type : "text" , text : `Reply ${ id } . ${ "Later work fills the timeline. " . repeat ( 12 ) } ` } ] ,
788+ { id, parentID, created } ,
789+ )
790+
791+ await setupTimeline ( page , {
792+ messages : [
793+ prompt ( "msg_0001_fold_edge" , lines ( "Edge" , 12 ) , 1700000000000 ) ,
794+ reply ( "msg_0002_fold_edge" , "msg_0001_fold_edge" , 1700000001000 ) ,
795+ prompt ( "msg_0003_fold_long" , lines ( "Long" , 13 ) , 1700000002000 ) ,
796+ reply ( "msg_0004_fold_long" , "msg_0003_fold_long" , 1700000003000 ) ,
797+ ...Array . from ( { length : 16 } , ( _ , index ) => {
798+ const created = 1700000010000 + index * 2_000
799+ const id = `msg_${ String ( 100 + index * 2 ) . padStart ( 4 , "0" ) } _fold_later`
800+
801+ return [ prompt ( id , `Later prompt ${ index } ` , created ) , reply ( `${ id } _reply` , id , created + 1_000 ) ]
802+ } ) . flat ( ) ,
803+ ] ,
804+ settings : { foldUserMessages : true } ,
805+ viewport : { width : 1200 , height : 700 } ,
806+ } )
807+
808+ const scroller = page . locator ( ".scroll-view__viewport" , { has : page . locator ( "[data-timeline-row]" ) } )
809+
810+ const row = ( id : string ) =>
811+ page . locator ( `[data-timeline-virtual-content] [data-timeline-row="UserMessage"][data-message-id="${ id } "]` )
812+
813+ const edge = row ( "msg_0001_fold_edge" )
814+ const long = row ( "msg_0003_fold_long" )
815+ const bubble = long . locator ( '[data-slot="user-message-text"]' )
816+ const fold = long . locator ( '[data-slot="user-message-fold"]' )
817+ const lastLater = row ( "msg_0130_fold_later" )
818+ await expect ( lastLater ) . toBeVisible ( )
819+
820+ // A wheel, not a scroll write, so the timeline stops following its end.
821+ await scroller . hover ( )
822+ await page . mouse . wheel ( 0 , - 100_000 )
823+ await expect ( long ) . toBeVisible ( )
824+
825+ // 12 lines show in full; 13 fold to 8, and all 13 stay in the text for copy and select-all.
826+ await expect ( edge . locator ( '[data-slot="user-message-fold"]' ) ) . toContainText ( "Edge line 12" )
827+ await expect ( edge . getByRole ( "button" , { name : "Read more" } ) ) . toHaveCount ( 0 )
828+ const toggle = long . getByRole ( "button" , { name : / ^ ( R e a d m o r e | S h o w l e s s ) $ / } )
829+ await expect ( toggle ) . toHaveAccessibleName ( "Read more" )
830+ await expect ( toggle ) . toHaveAttribute ( "aria-expanded" , "false" )
831+ const lineHeight = await fold . evaluate ( ( element ) => parseFloat ( getComputedStyle ( element ) . lineHeight ) )
832+ await expect . poll ( async ( ) => Math . round ( ( await fold . boundingBox ( ) ) ! . height / lineHeight ) ) . toBe ( 8 )
833+ expect (
834+ await fold . evaluate ( ( element ) => {
835+ const selection = getSelection ( ) !
836+ selection . selectAllChildren ( element )
837+ const text = selection . toString ( )
838+ selection . removeAllRanges ( )
839+
840+ return text
841+ } ) ,
842+ ) . toContain ( "Long line 13" )
843+
844+ // A cached tab's timeline is detached while hidden; the message stays folded rather than measuring 0 lines.
845+ expect (
846+ await bubble . evaluate ( async ( element ) => {
847+ const parent = element . parentElement !
848+ const next = element . nextSibling
849+ element . remove ( )
850+ await new Promise ( ( resolve ) => requestAnimationFrame ( ( ) => requestAnimationFrame ( resolve ) ) )
851+ const folded = element . querySelector ( '[data-slot="user-message-fold"]' ) ! . hasAttribute ( "data-folded" )
852+ parent . insertBefore ( element , next )
853+
854+ return folded
855+ } ) ,
856+ ) . toBe ( true )
857+ await expect ( toggle ) . toHaveAttribute ( "aria-expanded" , "false" )
858+
859+ // Opening grows the bubble downward, and the virtual row takes its new height.
860+ await bubble . evaluate ( ( element ) => element . scrollIntoView ( { block : "center" } ) )
861+ const top = ( await bubble . boundingBox ( ) ) ! . y
862+ await toggle . click ( )
863+ await expect ( toggle ) . toHaveAttribute ( "aria-expanded" , "true" )
864+ await expect ( toggle ) . toHaveAccessibleName ( "Show less" )
865+ await expect . poll ( async ( ) => Math . round ( ( await fold . boundingBox ( ) ) ! . height / lineHeight ) ) . toBe ( 13 )
866+ expect ( Math . abs ( ( await bubble . boundingBox ( ) ) ! . y - top ) ) . toBeLessThanOrEqual ( 1 )
867+ await expect
868+ . poll ( ( ) =>
869+ long . evaluate ( ( element ) => {
870+ const allocated = element . closest < HTMLElement > ( "[data-timeline-key]" ) ! . getBoundingClientRect ( ) . height
871+ const content = element . closest < HTMLElement > ( "[data-index]" ) ! . getBoundingClientRect ( ) . height
872+
873+ return Math . abs ( allocated - content )
874+ } ) ,
875+ )
876+ . toBeLessThanOrEqual ( 1 )
877+
878+ // The row unmounts at the end of the timeline and comes back open.
879+ await page . mouse . wheel ( 0 , 100_000 )
880+ await expect ( lastLater ) . toBeVisible ( )
881+ await expect ( long ) . toHaveCount ( 0 )
882+ await page . mouse . wheel ( 0 , - 100_000 )
883+ await expect ( long . getByRole ( "button" , { name : "Show less" } ) ) . toHaveAttribute ( "aria-expanded" , "true" )
884+ await expect . poll ( async ( ) => Math . round ( ( await fold . boundingBox ( ) ) ! . height / lineHeight ) ) . toBe ( 13 )
885+
886+ // Closing a message whose top is on screen shrinks it in place, without scrolling the timeline.
887+ await scroller . evaluate (
888+ ( element , target ) => {
889+ element . scrollTop += target ! . y - element . getBoundingClientRect ( ) . top - 100
890+ } ,
891+ await bubble . boundingBox ( ) ,
892+ )
893+
894+ const closing = {
895+ top : ( await bubble . boundingBox ( ) ) ! . y ,
896+ scroll : await scroller . evaluate ( ( element ) => element . scrollTop ) ,
897+ }
898+
899+ expect ( closing . scroll ) . toBeGreaterThan ( 150 )
900+ await toggle . click ( )
901+ await expect ( toggle ) . toHaveAttribute ( "aria-expanded" , "false" )
902+ expect ( Math . abs ( ( await bubble . boundingBox ( ) ) ! . y - closing . top ) ) . toBeLessThanOrEqual ( 1 )
903+ expect ( await scroller . evaluate ( ( element ) => element . scrollTop ) ) . toBe ( closing . scroll )
904+
905+ // Find skips the Read more button, and a match hidden in the fold opens the message.
906+ const highlights = ( ) =>
907+ page . evaluate ( ( ) =>
908+ [ "timeline-search-hit" , "timeline-search-hit-active" ] . reduce (
909+ ( count , name ) => count + ( CSS . highlights . get ( name ) ?. size ?? 0 ) ,
910+ 0 ,
911+ ) ,
912+ )
913+
914+ await page . keyboard . press ( "ControlOrMeta+f" )
915+ const search = page . locator ( '[data-component="timeline-search-bar"] input' )
916+ await expect ( search ) . toBeFocused ( )
917+ await search . fill ( "more" )
918+ await expect . poll ( highlights ) . toBe ( 0 )
919+ await search . fill ( "Long line 13" )
920+ await expect ( toggle ) . toHaveAttribute ( "aria-expanded" , "true" )
921+ await expect . poll ( highlights ) . toBe ( 1 )
922+ } )
0 commit comments