@@ -498,11 +498,39 @@ export type PrimaryOverlayKind =
498498 | "mcp"
499499 | "plugin_credentials" ;
500500
501- /** Whether the transcript viewport is stuck to the bottom (FOLLOW vs PINNED ). */
501+ /** True when the viewport is on the tail (`scrollTop >= max` ). */
502502export function isTranscriptFollowing ( shell : AppShell ) : boolean {
503503 const { transcript } = shell ;
504504 const max = Math . max ( 0 , transcript . scrollHeight - transcript . height ) ;
505- return transcript . scrollTop >= max - 1 ;
505+ return transcript . scrollTop >= max ;
506+ }
507+
508+ /** Bottom sticky stays armed only on the tail, so one detent up stays pinned. */
509+ export function bindTranscriptTailSticky (
510+ transcript : ScrollBoxRenderable ,
511+ ) : void {
512+ const desc = Object . getOwnPropertyDescriptor (
513+ Object . getPrototypeOf ( transcript ) ,
514+ "scrollTop" ,
515+ ) ;
516+ const get = desc ?. get ;
517+ const set = desc ?. set ;
518+ if ( get === undefined || set === undefined ) {
519+ throw new Error ( "ScrollBox scrollTop accessor is missing" ) ;
520+ }
521+ const sync = ( ) : void => {
522+ const max = Math . max ( 0 , transcript . scrollHeight - transcript . height ) ;
523+ transcript . stickyScroll = transcript . scrollTop >= max ;
524+ } ;
525+ Object . defineProperty ( transcript , "scrollTop" , {
526+ configurable : true ,
527+ enumerable : true ,
528+ get : ( ) => get . call ( transcript ) ,
529+ set : ( value : number ) => {
530+ set . call ( transcript , value ) ;
531+ sync ( ) ;
532+ } ,
533+ } ) ;
506534}
507535
508536/** Sticky-scroll mode label (surfaced on the notice row only when PINNED). */
@@ -546,6 +574,8 @@ export interface OverlayListRange {
546574 * rebuilds on geometry change. */
547575export interface OverlayList {
548576 readonly select : SelectRenderable ;
577+ /** ScrollBox that clips the select and owns the host ScrollBar. */
578+ readonly viewport : ScrollBoxRenderable ;
549579 readonly activeIndex : number ;
550580 /** Item-row capacity reserved by layout (not the renderable's row height). */
551581 readonly height : number ;
@@ -557,6 +587,8 @@ export interface OverlayList {
557587 jump ( index : number ) : void ;
558588 setCount ( count : number ) : void ;
559589 setHeight ( items : number , rowsPerItem ?: number ) : void ;
590+ /** Move the host ScrollBox so the active row is inside the viewport. */
591+ scrollActiveIntoView ( ) : void ;
560592 visibleRange ( ) : OverlayListRange ;
561593}
562594
@@ -723,11 +755,9 @@ interface ShellInternals {
723755
724756export const internals = new WeakMap < AppShell , ShellInternals > ( ) ;
725757
726- /** Filler row sized to leftover viewport space so a short transcript
727- * bottom-anchors against the prompt box; settles at zero once rows fill it.
728- * A real child, not padding: `minHeight: "100%"` counts padding as viewport
729- * height, so `scrollHeight - spacer.height` isolates the rows.
730- * Index 0 is always the spacer. */
758+ /** Index-0 child of the transcript. Height stays 0: short logs bottom-align
759+ * via the scroll content's justify-end, not a filler resized every frame.
760+ * The node remains so row children keep a stable offset. */
731761export const transcriptSpacers = new WeakMap < AppShell , BoxRenderable > ( ) ;
732762
733763/** True while the landing composition is still mounted. */
0 commit comments