char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
ba4e2f4
main
1import { assertEquals } from "@std/assert" ; 2import type { LiveState } from "@earendil-works/pi-durable" ; 3import { withDom } from "@berry/util/dom.ts" ; 4import { scroller } from "./scroller.tsx" ; 5import { Scope } from "./ui.ts" ; 6 7Deno . test ( "only new bottom content follows a reader near the previous bottom" , async () => { 8let resize = () => {}; 9await withDom ( async ( window ) => { 10const scope = new Scope (); 11try { 12const content = document . createElement ( "div" ); 13const older = document . createElement ( "div" ); 14const newest = document . createElement ( "div" ); 15content . append ( older , newest ); 16const element = scroller ( scope , content ); 17const height = 400 ; 18let total = 1000 ; 19let top = 0 ; 20Object . defineProperties ( element , { 21clientHeight :{ get :() => height }, 22scrollHeight :{ get :() => total }, 23scrollTop :{ 24get :() => top , 25set :( value :number ) => top = Math . max ( 0 , Math . min ( value , total - height )), 26}, 27}); 28resize (); 29assertEquals ( top , 600 ); 30const flush = () => window . happyDOM . waitUntilComplete (); 31 32total += 200 ; 33newest . textContent = "streaming" ; 34await flush (); 35assertEquals ( top , 800 ); 36 37// scrolling can precede the browser's scroll event 38element . dispatchEvent ( new Event ( "pointerdown" )); 39top = 700 ; 40total += 200 ; 41newest . firstChild ! . textContent = "still streaming" ; 42await flush (); 43assertEquals ( top , 700 ); 44element . dispatchEvent ( new Event ( "scroll" )); 45element . dispatchEvent ( new Event ( "pointerup" )); 46 47top = total - height - 65 ; 48element . dispatchEvent ( new Event ( "scroll" )); 49total += 200 ; 50newest . append ( " more" ); 51await flush (); 52assertEquals ( top , 935 ); 53 54top = total - height - 64 ; 55element . dispatchEvent ( new Event ( "scroll" )); 56total += 200 ; 57newest . append ( " more" ); 58await flush (); 59assertEquals ( top , 1400 ); 60 61// a layout change is not new output, but it moves the bottom a pinned reader follows 62total += 100 ; 63resize (); 64newest . append ( " more" ); 65await flush (); 66assertEquals ( top , 1500 ); 67 68top = total - height ; 69total += 200 ; 70older . textContent = "an older tool updated" ; 71await flush (); 72assertEquals ( top , 1500 ); 73 74top = total - height ; 75total += 200 ; 76content . prepend ( document . createElement ( "div" )); 77await flush (); 78assertEquals ( top , 1700 ); 79 80top = total - height ; 81total += 200 ; 82content . append ( document . createElement ( "div" )); 83await flush (); 84assertEquals ( top , 2100 ); 85 86scope . dispose (); 87total += 200 ; 88content . append ( document . createElement ( "div" )); 89await flush (); 90assertEquals ( top , 2100 ); 91} finally { 92scope . dispose (); 93} 94}, { 95globals :{ 96ResizeObserver :class { 97constructor ( callback :() => void ) { 98resize = callback ; 99} 100observe () {} 101disconnect () {} 102}, 103}, 104}); 105}); 106 107Deno . test ( "a resize keeps a reader at the bottom there, and leaves a reader above in place" , async () => { 108let resize = () => {}; 109await withDom ( async ( window ) => { 110const scope = new Scope (); 111try { 112const content = document . createElement ( "div" ); 113content . append ( document . createElement ( "div" )); 114const element = scroller ( scope , content ); 115let width = 800 ; 116let height = 400 ; 117let total = 1000 ; 118let top = 0 ; 119const clamp = ( value :number ) => Math . max ( 0 , Math . min ( value , total - height )); 120Object . defineProperties ( element , { 121clientWidth :{ get :() => width }, 122clientHeight :{ get :() => height }, 123scrollHeight :{ get :() => total }, 124scrollTop :{ get :() => top , set :( value :number ) => top = clamp ( value ) }, 125}); 126const output = async () => { 127total += 200 ; 128content . lastElementChild ! . append ( " more" ); 129await window . happyDOM . waitUntilComplete (); 130}; 131resize (); 132assertEquals ( top , 600 ); 133 134// the composer grows 135height = 300 ; 136resize (); 137assertEquals ( top , 700 ); 138 139// narrowing rewraps the transcript taller 140width = 600 ; 141total = 1300 ; 142resize (); 143assertEquals ( top , 1000 ); 144await output (); 145assertEquals ( top , 1200 ); 146 147// widening clamps the reader, whose scroll event can arrive first 148width = 800 ; 149total = 1100 ; 150top = clamp ( top ); 151element . dispatchEvent ( new Event ( "scroll" )); 152resize (); 153await output (); 154assertEquals ( top , 1000 ); 155 156top = 500 ; 157element . dispatchEvent ( new Event ( "scroll" )); 158height = 200 ; 159resize (); 160assertEquals ( top , 500 ); 161await output (); 162assertEquals ( top , 500 ); 163} finally { 164scope . dispose (); 165} 166}, { 167globals :{ 168ResizeObserver :class { 169constructor ( callback :() => void ) { 170resize = callback ; 171} 172observe () {} 173disconnect () {} 174}, 175}, 176}); 177}); 178 179Deno . test ( "a pinned reader stays at the bottom through layout changes larger than the follow margin" , async () => { 180let resize = () => {}; 181await withDom ( async ( window ) => { 182const scope = new Scope (); 183try { 184const content = document . createElement ( "div" ); 185content . append ( document . createElement ( "div" )); 186const element = scroller ( scope , content ); 187let height = 400 ; 188let total = 1000 ; 189let top = 0 ; 190Object . defineProperties ( element , { 191clientHeight :{ get :() => height }, 192scrollHeight :{ get :() => total }, 193scrollTop :{ 194get :() => top , 195set :( value :number ) => top = Math . max ( 0 , Math . min ( value , total - height )), 196}, 197}); 198resize (); 199assertEquals ( top , 600 ); 200 201// the existing content grows, as an image or font loading makes it 202total += 300 ; 203content . prepend ( document . createElement ( "div" )); 204await window . happyDOM . waitUntilComplete (); 205element . dispatchEvent ( new Event ( "scroll" )); 206resize (); 207assertEquals ( top , 900 ); 208 209// a scroll event from the pane shrinking arrives before the resize settles it 210height = 300 ; 211element . dispatchEvent ( new Event ( "scroll" )); 212resize (); 213assertEquals ( top , 1000 ); 214 215// the composer grows in successive steps, as its textarea gains a row and then its controls stack 216height = 260 ; 217resize (); 218assertEquals ( top , 1040 ); 219height = 220 ; 220resize (); 221assertEquals ( top , 1080 ); 222 223top = 500 ; 224element . dispatchEvent ( new Event ( "scroll" )); 225height = 180 ; 226resize (); 227assertEquals ( top , 500 ); 228} finally { 229scope . dispose (); 230} 231}, { 232globals :{ 233ResizeObserver :class { 234constructor ( callback :() => void ) { 235resize = callback ; 236} 237observe () {} 238disconnect () {} 239}, 240}, 241}); 242}); 243 244Deno . test ( "a reader scrolled up can jump back to the newest output, and follows it again" , async () => { 245let resize = () => {}; 246await withDom ( async ( window ) => { 247const scope = new Scope (); 248try { 249const content = document . createElement ( "div" ); 250content . append ( document . createElement ( "div" )); 251const element = scroller ( scope , content ); 252let total = 1000 ; 253let top = 0 ; 254Object . defineProperties ( element , { 255clientHeight :{ value :400 }, 256scrollHeight :{ get :() => total }, 257scrollTop :{ 258get :() => top , 259set :( value :number ) => top = Math . max ( 0 , Math . min ( value , total - 400 )), 260}, 261}); 262const jump = element . querySelector < HTMLButtonElement >( 263'button[aria-label="Jump to the newest output"]' , 264) ! ; 265const shown = () => jump . closest ( "[hidden]" ) === null ; 266resize (); 267assertEquals ( shown (), false ); 268 269top = 100 ; 270element . dispatchEvent ( new Event ( "scroll" )); 271assertEquals ( shown (), true ); 272 273jump . click (); 274assertEquals ( top , 600 ); 275assertEquals ( shown (), false ); 276total += 200 ; 277content . lastElementChild ! . append ( "more" ); 278await window . happyDOM . waitUntilComplete (); 279assertEquals ( top , 800 ); 280} finally { 281scope . dispose (); 282} 283}, { 284globals :{ 285ResizeObserver :class { 286constructor ( callback :() => void ) { 287resize = callback ; 288} 289observe () {} 290disconnect () {} 291}, 292}, 293}); 294}); 295 296Deno . test ( "a transcript waits for visible, scrollable content before its initial scroll" , async () => { 297let resize = () => {}; 298await withDom ( async ( window ) => { 299const scope = new Scope (); 300try { 301const content = document . createElement ( "div" ); 302const element = scroller ( scope , content ); 303let visible = true ; 304let total = 400 ; 305let top = 0 ; 306Object . defineProperties ( element , { 307clientHeight :{ get :() => visible ?400 :0 }, 308scrollHeight :{ get :() => visible ?total :0 }, 309scrollTop :{ 310get :() => top , 311set :( value :number ) => top = visible ?Math . max ( 0 , Math . min ( value , total - 400 )) :0 , 312}, 313}); 314resize (); 315content . append ( document . createElement ( "div" )); 316await window . happyDOM . waitUntilComplete (); 317assertEquals ( top , 0 ); 318visible = false ; 319resize (); 320content . append ( document . createElement ( "div" )); 321total = 1000 ; 322await window . happyDOM . waitUntilComplete (); 323resize (); 324assertEquals ( top , 0 ); 325 326visible = true ; 327resize (); 328assertEquals ( top , 600 ); 329 330top = 200 ; 331element . dispatchEvent ( new Event ( "scroll" )); 332total += 200 ; 333resize (); 334assertEquals ( top , 200 ); 335visible = false ; 336resize (); 337visible = true ; 338resize (); 339assertEquals ( top , 200 ); 340} finally { 341scope . dispose (); 342} 343}, { 344globals :{ 345ResizeObserver :class { 346constructor ( callback :() => void ) { 347resize = callback ; 348} 349observe () {} 350disconnect () {} 351}, 352}, 353}); 354}); 355 356Deno . test ( "a scroller has its content draw more as the reader scrolls, and as it resizes" , async () => { 357let resize = () => {}; 358await withDom (() => { 359const scope = new Scope (); 360try { 361const content = document . createElement ( "div" ); 362content . append ( document . createElement ( "div" )); 363const drawn :number [] = []; 364const element = scroller ( scope , content , undefined , ( pane ) => drawn . push ( pane . clientHeight )); 365Object . defineProperties ( element , { 366clientHeight :{ value :400 }, 367scrollHeight :{ value :1000 }, 368scrollTop :{ value :600 , writable :true }, 369}); 370resize (); 371assertEquals ( drawn , [ 400 ]); 372element . dispatchEvent ( new Event ( "scroll" )); 373assertEquals ( drawn , [ 400 , 400 ]); 374resize (); 375assertEquals ( drawn , [ 400 , 400 , 400 ]); 376} finally { 377scope . dispose (); 378} 379}, { 380globals :{ 381ResizeObserver :class { 382constructor ( callback :() => void ) { 383resize = callback ; 384} 385observe () {} 386disconnect () {} 387}, 388}, 389}); 390}); 391 392Deno . test ( "a transcript follows its last block, not updates to earlier blocks in the same response" , async () => { 393await withDom ( async ( window ) => { 394const { transcript} = await import ( "./transcript.tsx" ); 395const { fauxAssistantMessage, fauxText} = await import ( 396"@earendil-works/pi-ai/providers/faux" 397); 398const scope = new Scope (); 399const history = transcript (); 400scope . add ( history . dispose ); 401const element = scroller ( scope , history . element , history . last ); 402let total = 1000 ; 403let top = 0 ; 404Object . defineProperties ( element , { 405clientHeight :{ value :400 }, 406scrollHeight :{ get :() => total }, 407scrollTop :{ 408get :() => top , 409set :( value :number ) => top = Math . max ( 0 , Math . min ( value , total - 400 )), 410}, 411}); 412const entries :never [] = []; 413const update = async ( first :string , last :string ) => { 414history . update ( entries , { 415generation :{ 416attempt :0 , 417message :fauxAssistantMessage ([ fauxText ( first ), fauxText ( last )]), 418}, 419} as unknown as LiveState ); 420await window . happyDOM . waitUntilComplete (); 421}; 422try { 423await update ( "first" , "last" ); 424assertEquals ( top , 600 ); 425total += 200 ; 426await update ( "first updated" , "last" ); 427assertEquals ( top , 600 ); 428top = 800 ; 429total += 200 ; 430await update ( "first updated" , "last updated" ); 431assertEquals ( top , 1000 ); 432} finally { 433scope . dispose (); 434} 435}); 436});