@@ -43,6 +43,11 @@ interface MockVirtuosoProps {
4343
4444interface VirtuosoHarnessState {
4545 scrollCalls : number
46+ scrollToIndexArgs : Array < {
47+ index : number | "LAST"
48+ align ?: "end" | "start" | "center"
49+ behavior ?: "auto" | "smooth"
50+ } >
4651 atBottomAfterCalls : number
4752 signalDelayMs : number
4853 emitFalseOnDataChange : boolean
@@ -54,6 +59,7 @@ interface VirtuosoHarnessState {
5459
5560const harness = vi . hoisted < VirtuosoHarnessState > ( ( ) => ( {
5661 scrollCalls : 0 ,
62+ scrollToIndexArgs : [ ] ,
5763 atBottomAfterCalls : Number . POSITIVE_INFINITY ,
5864 signalDelayMs : 20 ,
5965 emitFalseOnDataChange : true ,
@@ -152,8 +158,9 @@ vi.mock("react-virtuoso", () => {
152158 }
153159
154160 useImperativeHandle ( ref , ( ) => ( {
155- scrollToIndex : ( ) => {
161+ scrollToIndex : ( options ) => {
156162 harness . scrollCalls += 1
163+ harness . scrollToIndexArgs . push ( options )
157164 const reachedBottom = harness . scrollCalls >= harness . atBottomAfterCalls
158165 const timeoutId = window . setTimeout ( ( ) => {
159166 atBottomRef . current ?.( reachedBottom )
@@ -215,6 +222,23 @@ const buildMessages = (baseTs: number): ClineMessage[] => [
215222 { type : "say" , say : "text" , ts : baseTs + 2 , text : "row-2" } ,
216223]
217224
225+ const buildMessagesWithCheckpoint = ( baseTs : number ) : ClineMessage [ ] => [
226+ { type : "say" , say : "text" , ts : baseTs , text : "task" } ,
227+ { type : "say" , say : "text" , ts : baseTs + 1 , text : "row-1" } ,
228+ { type : "say" , say : "checkpoint_saved" , ts : baseTs + 2 , text : "checkpoint-1" } ,
229+ { type : "say" , say : "text" , ts : baseTs + 3 , text : "row-2" } ,
230+ ]
231+
232+ const buildMessagesWithMultipleCheckpoints = ( baseTs : number ) : ClineMessage [ ] => [
233+ { type : "say" , say : "text" , ts : baseTs , text : "task" } ,
234+ { type : "say" , say : "checkpoint_saved" , ts : baseTs + 1 , text : "checkpoint-1" } ,
235+ { type : "say" , say : "text" , ts : baseTs + 2 , text : "row-2" } ,
236+ { type : "say" , say : "checkpoint_saved" , ts : baseTs + 3 , text : "checkpoint-2" } ,
237+ { type : "say" , say : "text" , ts : baseTs + 4 , text : "row-4" } ,
238+ { type : "say" , say : "checkpoint_saved" , ts : baseTs + 5 , text : "checkpoint-3" } ,
239+ { type : "say" , say : "text" , ts : baseTs + 6 , text : "row-6" } ,
240+ ]
241+
218242const resolveFollowOutput = ( isAtBottom : boolean ) : "auto" | false => {
219243 const followOutput = harness . followOutput
220244 if ( typeof followOutput === "function" ) {
@@ -254,19 +278,19 @@ const renderView = () =>
254278 </ ExtensionStateContextProvider > ,
255279 )
256280
257- const hydrate = async ( atBottomAfterCalls : number ) => {
281+ const hydrate = async ( atBottomAfterCalls : number , clineMessages = buildMessages ( Date . now ( ) - 3_000 ) ) => {
258282 harness . atBottomAfterCalls = atBottomAfterCalls
259283 renderView ( )
260284 await act ( async ( ) => {
261285 await Promise . resolve ( )
262286 } )
263287 await act ( async ( ) => {
264- postState ( buildMessages ( Date . now ( ) - 3_000 ) )
288+ postState ( clineMessages )
265289 } )
266290 await waitFor ( ( ) => {
267291 const list = document . querySelector ( "[data-testid='virtuoso-item-list']" )
268292 expect ( list ) . toBeTruthy ( )
269- expect ( list ?. getAttribute ( "data-count" ) ) . toBe ( "2" )
293+ expect ( list ?. getAttribute ( "data-count" ) ) . toBe ( String ( Math . max ( 0 , clineMessages . length - 1 ) ) )
270294 } )
271295}
272296
@@ -317,9 +341,19 @@ const getScrollToBottomButton = (): HTMLButtonElement => {
317341 return button
318342}
319343
344+ const getScrollToCheckpointButton = ( ) : HTMLButtonElement => {
345+ const button = document . querySelector ( "button[aria-label='chat:scrollToLatestCheckpoint']" )
346+ if ( ! ( button instanceof HTMLButtonElement ) ) {
347+ throw new Error ( "Expected scroll-to-checkpoint button" )
348+ }
349+
350+ return button
351+ }
352+
320353describe ( "ChatView scroll behavior regression coverage" , ( ) => {
321354 beforeEach ( ( ) => {
322355 harness . scrollCalls = 0
356+ harness . scrollToIndexArgs = [ ]
323357 harness . atBottomAfterCalls = Number . POSITIVE_INFINITY
324358 harness . signalDelayMs = 20
325359 harness . emitFalseOnDataChange = true
@@ -503,4 +537,71 @@ describe("ChatView scroll behavior regression coverage", () => {
503537 } )
504538 await waitFor ( ( ) => expect ( document . querySelector ( ".codicon-chevron-down" ) ) . toBeNull ( ) , { timeout : 1_200 } )
505539 } )
540+
541+ it ( "shows jump-to-checkpoint button and scrolls to latest checkpoint" , async ( ) => {
542+ await hydrate ( 2 , buildMessagesWithCheckpoint ( Date . now ( ) - 3_000 ) )
543+ await waitForCalls ( 2 )
544+ await waitForCallsSettled ( )
545+
546+ await act ( async ( ) => {
547+ fireEvent . keyDown ( window , { key : "PageUp" } )
548+ } )
549+
550+ await waitFor ( ( ) => expect ( document . querySelector ( ".codicon-chevron-down" ) ) . toBeTruthy ( ) , {
551+ timeout : 1_200 ,
552+ } )
553+
554+ const checkpointButton = document . querySelector ( "button[aria-label='chat:scrollToLatestCheckpoint']" )
555+ expect ( checkpointButton ) . toBeInstanceOf ( HTMLButtonElement )
556+
557+ const callsBeforeClick = harness . scrollCalls
558+
559+ await act ( async ( ) => {
560+ ; ( checkpointButton as HTMLButtonElement ) . click ( )
561+ } )
562+
563+ expect ( harness . scrollCalls ) . toBe ( callsBeforeClick + 1 )
564+ expect ( harness . scrollToIndexArgs . at ( - 1 ) ) . toMatchObject ( {
565+ index : 1 ,
566+ align : "center" ,
567+ behavior : "smooth" ,
568+ } )
569+ } )
570+
571+ it ( "repeated checkpoint clicks step backward through previous checkpoints" , async ( ) => {
572+ await hydrate ( 2 , buildMessagesWithMultipleCheckpoints ( Date . now ( ) - 3_000 ) )
573+ await waitForCalls ( 2 )
574+ await waitForCallsSettled ( )
575+
576+ await act ( async ( ) => {
577+ fireEvent . keyDown ( window , { key : "PageUp" } )
578+ } )
579+
580+ await waitFor ( ( ) => expect ( document . querySelector ( ".codicon-chevron-down" ) ) . toBeTruthy ( ) , {
581+ timeout : 1_200 ,
582+ } )
583+
584+ const checkpointButton = getScrollToCheckpointButton ( )
585+
586+ await act ( async ( ) => {
587+ ; ( checkpointButton as HTMLButtonElement ) . click ( )
588+ } )
589+ expect ( harness . scrollToIndexArgs . at ( - 1 ) ) . toMatchObject ( { index : 4 , align : "center" , behavior : "smooth" } )
590+
591+ await act ( async ( ) => {
592+ ; ( checkpointButton as HTMLButtonElement ) . click ( )
593+ } )
594+ expect ( harness . scrollToIndexArgs . at ( - 1 ) ) . toMatchObject ( { index : 2 , align : "center" , behavior : "smooth" } )
595+
596+ await act ( async ( ) => {
597+ ; ( checkpointButton as HTMLButtonElement ) . click ( )
598+ } )
599+ expect ( harness . scrollToIndexArgs . at ( - 1 ) ) . toMatchObject ( { index : 0 , align : "center" , behavior : "smooth" } )
600+
601+ // Once at the oldest checkpoint, additional clicks keep targeting it.
602+ await act ( async ( ) => {
603+ ; ( checkpointButton as HTMLButtonElement ) . click ( )
604+ } )
605+ expect ( harness . scrollToIndexArgs . at ( - 1 ) ) . toMatchObject ( { index : 0 , align : "center" , behavior : "smooth" } )
606+ } )
506607} )
0 commit comments