11"""Chat panel for displaying and interacting with chat messages."""
22
33import time
4+ from collections import deque
45
56from textual .app import ComposeResult
67from textual .containers import Container , Vertical , VerticalScroll
1213
1314from uipath .dev .models import ChatMessage , ExecutionRun
1415
16+ # Tunables for streaming performance
17+ STREAM_MIN_INTERVAL = 0.08 # seconds between updates while streaming
18+ STREAM_MIN_DELTA_CHARS = 8 # min new chars before we bother updating
19+
20+ # Limit how many message widgets we keep mounted to avoid DOM explosion.
21+ MAX_WIDGETS = 20
22+
1523
1624class Prompt (Markdown ):
1725 """User prompt message bubble."""
@@ -36,12 +44,18 @@ class ChatPanel(Container):
3644
3745 _chat_widgets : dict [str , Markdown ]
3846 _last_update_time : dict [str , float ]
47+ _last_content : dict [str , str ]
48+ _chat_view : VerticalScroll | None
49+ _chat_order : deque [str ]
3950
4051 def __init__ (self , ** kwargs ):
4152 """Initialize the chat panel."""
4253 super ().__init__ (** kwargs )
4354 self ._chat_widgets = {}
4455 self ._last_update_time = {}
56+ self ._last_content = {}
57+ self ._chat_view = None
58+ self ._chat_order = deque ()
4559
4660 def compose (self ) -> ComposeResult :
4761 """Compose the UI layout."""
@@ -52,34 +66,46 @@ def compose(self) -> ComposeResult:
5266 id = "chat-input" ,
5367 )
5468
55- def update_messages (self , run : ExecutionRun ) -> None :
69+ def on_mount (self ) -> None :
70+ """Called when the panel is mounted."""
71+ self ._chat_view = self .query_one ("#chat-view" , VerticalScroll )
72+
73+ def refresh_messages (self , run : ExecutionRun ) -> None :
5674 """Update the chat panel with messages from the given execution run."""
57- chat_view = self .query_one ("#chat-view" )
58- chat_view .remove_children ()
75+ assert self ._chat_view is not None
76+
77+ self ._chat_view .remove_children ()
5978 self ._chat_widgets .clear ()
6079 self ._last_update_time .clear ()
80+ self ._last_content .clear ()
81+ self ._chat_order .clear ()
6182
6283 for chat_msg in run .messages :
6384 self .add_chat_message (
6485 ChatMessage (message = chat_msg , event = None , run_id = run .id ),
6586 auto_scroll = False ,
6687 )
6788
68- chat_view .scroll_end (animate = False )
89+ # For a fresh run, always show the latest messages
90+ self ._chat_view .scroll_end (animate = False )
6991
7092 def add_chat_message (
7193 self ,
7294 chat_msg : ChatMessage ,
7395 auto_scroll : bool = True ,
7496 ) -> None :
7597 """Add or update a chat message bubble."""
76- chat_view = self .query_one ("#chat-view" )
98+ assert self ._chat_view is not None
99+ chat_view = self ._chat_view
77100
78- message = chat_msg . message
101+ should_autoscroll = auto_scroll and not chat_view . is_vertical_scrollbar_grabbed
79102
103+ message = chat_msg .message
80104 if message is None :
81105 return
82106
107+ message_id = message .message_id
108+
83109 widget_cls : type [Prompt ] | type [Response ] | type [Tool ]
84110 if message .role == "user" :
85111 widget_cls = Prompt
@@ -114,27 +140,82 @@ def add_chat_message(
114140
115141 content = "\n \n " .join (content_lines )
116142
117- existing = self ._chat_widgets .get (message .message_id )
143+ prev_content = self ._last_content .get (message_id )
144+ if prev_content is not None and content == prev_content :
145+ # We already rendered this exact content, no need to touch the UI.
146+ return
147+
148+ existing = self ._chat_widgets .get (message_id )
118149 now = time .monotonic ()
119- last_update = self ._last_update_time .get (message . message_id , 0.0 )
150+ last_update = self ._last_update_time .get (message_id , 0.0 )
120151
121152 if existing :
122- event = chat_msg .event
123- should_update = (
124- event
125- and event .exchange
126- and event .exchange .message
127- and event .exchange .message .end is not None
128- )
129- if should_update or now - last_update > 0.15 :
153+ prev_content_len = len (prev_content ) if prev_content is not None else 0
154+ delta_len = len (content ) - prev_content_len
155+
156+ def should_update () -> bool :
157+ event = chat_msg .event
158+ finished = (
159+ event
160+ and event .exchange
161+ and event .exchange .message
162+ and event .exchange .message .end is not None
163+ )
164+
165+ if finished :
166+ # Always paint the final state immediately.
167+ return True
168+
169+ # Throttle streaming: require both some time and a minimum delta size.
170+ if now - last_update < STREAM_MIN_INTERVAL :
171+ return False
172+
173+ # First streaming chunk for this message: allow update.
174+ if prev_content is None :
175+ return True
176+
177+ if delta_len < STREAM_MIN_DELTA_CHARS :
178+ return False
179+
180+ return True
181+
182+ if not should_update ():
183+ return
184+
185+ # Fast path: message is growing by appending new text.
186+ if (
187+ isinstance (existing , Markdown )
188+ and prev_content is not None
189+ and content .startswith (prev_content )
190+ ):
191+ delta = content [len (prev_content ) :]
192+ if delta :
193+ # Streaming update: only append the new portion.
194+ existing .append (delta )
195+ else :
196+ # Fallback for non-monotonic changes: full update.
130197 existing .update (content )
131- self ._last_update_time [message .message_id ] = now
132- if auto_scroll :
133- chat_view .scroll_end (animate = False )
198+
199+ self ._last_content [message_id ] = content
200+ self ._last_update_time [message_id ] = now
201+
134202 else :
203+ # First time we see this message: create a new widget.
135204 widget_instance = widget_cls (content )
136205 chat_view .mount (widget_instance )
137- self ._chat_widgets [message .message_id ] = widget_instance
138- self ._last_update_time [message .message_id ] = now
139- if auto_scroll :
140- chat_view .scroll_end (animate = False )
206+ self ._chat_widgets [message_id ] = widget_instance
207+ self ._last_update_time [message_id ] = now
208+ self ._last_content [message_id ] = content
209+ self ._chat_order .append (message_id )
210+
211+ # Prune oldest widgets to keep DOM size bounded
212+ if len (self ._chat_order ) > MAX_WIDGETS :
213+ oldest_id = self ._chat_order .popleft ()
214+ old_widget = self ._chat_widgets .pop (oldest_id , None )
215+ self ._last_update_time .pop (oldest_id , None )
216+ self ._last_content .pop (oldest_id , None )
217+ if old_widget is not None :
218+ old_widget .remove ()
219+
220+ if should_autoscroll :
221+ chat_view .scroll_end (animate = False )
0 commit comments