@@ -13,6 +13,7 @@ import (
1313 "github.com/docker/cagent/pkg/chat"
1414 "github.com/docker/cagent/pkg/session"
1515 "github.com/docker/cagent/pkg/tools"
16+ "github.com/docker/cagent/pkg/tui/animation"
1617 "github.com/docker/cagent/pkg/tui/components/reasoningblock"
1718 "github.com/docker/cagent/pkg/tui/core/layout"
1819 "github.com/docker/cagent/pkg/tui/service"
@@ -665,3 +666,154 @@ func TestRenderCacheInvalidatesOnChildUpdate(t *testing.T) {
665666 assert .Contains (t , view2 , "frame-1" )
666667 assert .NotEqual (t , view1 , view2 , "View should change after Update with non-nil child cmd" )
667668}
669+
670+ func TestRenderCacheInvalidatesOnAnimationTickWithAnimatedContent (t * testing.T ) {
671+ t .Parallel ()
672+
673+ sessionState := & service.SessionState {}
674+ m := NewScrollableView (80 , 24 , sessionState ).(* model )
675+ m .SetSize (80 , 24 )
676+
677+ // Add a running tool call which has a spinner (animated content)
678+ toolMsg := types .ToolCallMessage ("root" , tools.ToolCall {
679+ ID : "call-1" ,
680+ Function : tools.FunctionCall {Name : "running_tool" , Arguments : `{}` },
681+ }, tools.Tool {Name : "running_tool" , Description : "A running tool" }, types .ToolStatusRunning )
682+ m .messages = append (m .messages , toolMsg )
683+ m .views = append (m .views , m .createToolCallView (toolMsg ))
684+ m .renderDirty = true
685+
686+ // First render
687+ view1 := m .View ()
688+ require .Contains (t , view1 , "running_tool" )
689+
690+ // Clear the dirty flag to simulate cached state
691+ m .renderDirty = false
692+
693+ // Send animation tick - should invalidate cache because we have animated content
694+ m .Update (animation.TickMsg {Frame : 1 })
695+
696+ // Cache should be marked dirty
697+ assert .True (t , m .renderDirty , "renderDirty should be true after animation tick with animated content" )
698+ }
699+
700+ func TestRenderCacheNotInvalidatedOnAnimationTickWithoutAnimatedContent (t * testing.T ) {
701+ t .Parallel ()
702+
703+ sessionState := & service.SessionState {}
704+ m := NewScrollableView (80 , 24 , sessionState ).(* model )
705+ m .SetSize (80 , 24 )
706+
707+ // Add a completed tool call (no spinner - not animated)
708+ toolMsg := types .ToolCallMessage ("root" , tools.ToolCall {
709+ ID : "call-1" ,
710+ Function : tools.FunctionCall {Name : "completed_tool" , Arguments : `{}` },
711+ }, tools.Tool {Name : "completed_tool" , Description : "A completed tool" }, types .ToolStatusCompleted )
712+ m .messages = append (m .messages , toolMsg )
713+ m .views = append (m .views , m .createToolCallView (toolMsg ))
714+ m .renderDirty = true
715+
716+ // First render
717+ view1 := m .View ()
718+ require .Contains (t , view1 , "completed_tool" )
719+
720+ // Clear the dirty flag to simulate cached state
721+ m .renderDirty = false
722+
723+ // Send animation tick - should NOT invalidate cache because no animated content
724+ m .Update (animation.TickMsg {Frame : 1 })
725+
726+ // Cache should still be clean (not dirty)
727+ assert .False (t , m .renderDirty , "renderDirty should remain false after animation tick without animated content" )
728+ }
729+
730+ func TestHasAnimatedContent (t * testing.T ) {
731+ t .Parallel ()
732+
733+ tests := []struct {
734+ name string
735+ setupFunc func (m * model )
736+ wantAnimated bool
737+ }{
738+ {
739+ name : "empty model" ,
740+ setupFunc : func (_ * model ) {},
741+ wantAnimated : false ,
742+ },
743+ {
744+ name : "spinner message" ,
745+ setupFunc : func (m * model ) {
746+ msg := types .Spinner ()
747+ m .messages = append (m .messages , msg )
748+ m .views = append (m .views , m .createMessageView (msg ))
749+ },
750+ wantAnimated : true ,
751+ },
752+ {
753+ name : "loading message" ,
754+ setupFunc : func (m * model ) {
755+ msg := types .Loading ("Loading..." )
756+ m .messages = append (m .messages , msg )
757+ m .views = append (m .views , m .createMessageView (msg ))
758+ },
759+ wantAnimated : true ,
760+ },
761+ {
762+ name : "pending tool call" ,
763+ setupFunc : func (m * model ) {
764+ toolMsg := types .ToolCallMessage ("root" , tools.ToolCall {
765+ ID : "call-1" ,
766+ Function : tools.FunctionCall {Name : "pending_tool" , Arguments : `{}` },
767+ }, tools.Tool {Name : "pending_tool" }, types .ToolStatusPending )
768+ m .messages = append (m .messages , toolMsg )
769+ m .views = append (m .views , m .createToolCallView (toolMsg ))
770+ },
771+ wantAnimated : true ,
772+ },
773+ {
774+ name : "running tool call" ,
775+ setupFunc : func (m * model ) {
776+ toolMsg := types .ToolCallMessage ("root" , tools.ToolCall {
777+ ID : "call-1" ,
778+ Function : tools.FunctionCall {Name : "running_tool" , Arguments : `{}` },
779+ }, tools.Tool {Name : "running_tool" }, types .ToolStatusRunning )
780+ m .messages = append (m .messages , toolMsg )
781+ m .views = append (m .views , m .createToolCallView (toolMsg ))
782+ },
783+ wantAnimated : true ,
784+ },
785+ {
786+ name : "completed tool call" ,
787+ setupFunc : func (m * model ) {
788+ toolMsg := types .ToolCallMessage ("root" , tools.ToolCall {
789+ ID : "call-1" ,
790+ Function : tools.FunctionCall {Name : "completed_tool" , Arguments : `{}` },
791+ }, tools.Tool {Name : "completed_tool" }, types .ToolStatusCompleted )
792+ m .messages = append (m .messages , toolMsg )
793+ m .views = append (m .views , m .createToolCallView (toolMsg ))
794+ },
795+ wantAnimated : false ,
796+ },
797+ {
798+ name : "assistant message" ,
799+ setupFunc : func (m * model ) {
800+ msg := types .Agent (types .MessageTypeAssistant , "root" , "Hello" )
801+ m .messages = append (m .messages , msg )
802+ m .views = append (m .views , m .createMessageView (msg ))
803+ },
804+ wantAnimated : false ,
805+ },
806+ }
807+
808+ for _ , tt := range tests {
809+ t .Run (tt .name , func (t * testing.T ) {
810+ t .Parallel ()
811+ sessionState := & service.SessionState {}
812+ m := NewScrollableView (80 , 24 , sessionState ).(* model )
813+ m .SetSize (80 , 24 )
814+ tt .setupFunc (m )
815+ got := m .hasAnimatedContent ()
816+ assert .Equal (t , tt .wantAnimated , got )
817+ })
818+ }
819+ }
0 commit comments