4
4
# Imports
5
5
#
6
6
# 3rd-party Libraries
7
+ import logging
7
8
from typing import Optional
8
9
9
10
from textual .app import ComposeResult
10
11
from textual .containers import Horizontal , Vertical
12
+ from textual .css .query import QueryError
11
13
from textual .widget import Widget
12
14
from textual .widgets import Static , Button , Label # Added Label
13
15
from textual .reactive import reactive
@@ -83,8 +85,9 @@ class ChatMessage(Widget):
83
85
84
86
# Store the raw text content
85
87
message_text = reactive ("" , repaint = True )
86
- role = reactive ("User" , repaint = True ) # "User" or "AI"
87
- generation_complete = reactive (True ) # Used for AI messages to show actions
88
+ role = reactive ("User" , repaint = True )
89
+ # Use an internal reactive to manage generation status and trigger UI updates
90
+ _generation_complete_internal = reactive (True )
88
91
89
92
# -- Internal state for message metadata ---
90
93
message_id_internal : reactive [Optional [str ]] = reactive (None )
@@ -108,7 +111,7 @@ def __init__(self,
108
111
super ().__init__ (** kwargs )
109
112
self .message_text = message
110
113
self .role = role
111
- self .generation_complete = generation_complete
114
+ self ._generation_complete_internal = generation_complete
112
115
113
116
self .message_id_internal = message_id
114
117
self .message_version_internal = message_version
@@ -124,6 +127,11 @@ def __init__(self,
124
127
else : # Any role other than "user" (e.g., "AI", "Default Assistant", "Character Name") gets the -ai style
125
128
self .add_class ("-ai" )
126
129
130
+ @property
131
+ def generation_complete (self ) -> bool :
132
+ """Public property to access the generation status."""
133
+ return self ._generation_complete_internal
134
+
127
135
def compose (self ) -> ComposeResult :
128
136
with Vertical ():
129
137
yield Label (f"{ self .role } " , classes = "message-header" )
@@ -133,7 +141,9 @@ def compose(self) -> ComposeResult:
133
141
# This should only apply if it's an AI message AND generation is not complete
134
142
if self .has_class ("-ai" ) and not self .generation_complete :
135
143
actions_class += " -generating"
136
- with Horizontal (classes = actions_class ):
144
+
145
+ with Horizontal (classes = actions_class ) as actions_bar :
146
+ actions_bar .id = f"actions-bar-{ self .id or self .message_id_internal or 'new' } "
137
147
# Common buttons
138
148
yield Button ("Edit" , classes = "action-button edit-button" )
139
149
yield Button ("📋" , classes = "action-button copy-button" , id = "copy" ) # Emoji for copy
@@ -144,25 +154,67 @@ def compose(self) -> ComposeResult:
144
154
yield Button ("👍" , classes = "action-button thumb-up-button" , id = "thumb-up" )
145
155
yield Button ("👎" , classes = "action-button thumb-down-button" , id = "thumb-down" )
146
156
yield Button ("🔄" , classes = "action-button regenerate-button" , id = "regenerate" ) # Emoji for regenerate
147
- if self . generation_complete : # Only show continue if generation is complete
148
- yield Button ("↪️" , id = "continue-response-button" , classes = "action-button continue-button" )
157
+ # FIXME For some reason, the entire UI freezes when clicked...
158
+ # yield Button("↪️", id="continue-response-button", classes="action-button continue-button")
149
159
150
160
# Add delete button for all messages at very end
151
161
yield Button ("🗑️" , classes = "action-button delete-button" ) # Emoji for delete ; Label: Delete, Class: delete-button
152
162
153
- def update_message_chunk (self , chunk : str ):
163
+ def watch__generation_complete_internal (self , complete : bool ) -> None :
164
+ """
165
+ Watcher for the internal generation status.
166
+ Updates the actions bar visibility and the continue button visibility for AI messages.
167
+ """
154
168
if self .has_class ("-ai" ):
155
- self .query_one (".message-text" , Static ).update (self .message_text + chunk )
156
- self .message_text += chunk
169
+ try :
170
+ actions_container = self .query_one (".message-actions" )
171
+ continue_button = self .query_one ("#continue-response-button" , Button )
172
+
173
+ if complete :
174
+ actions_container .remove_class ("-generating" ) # Makes the bar visible via CSS
175
+ actions_container .styles .display = "block" # Ensures bar is visible
176
+ continue_button .display = True # Makes continue button visible
177
+ else :
178
+ # This state typically occurs during initialization if generation_complete=False
179
+ actions_container .add_class ("-generating" ) # Hides the bar via CSS
180
+ # actions_container.styles.display = "none" # CSS rule should handle this
181
+ continue_button .display = False # Hides continue button
182
+ except QueryError as qe :
183
+ # This might happen if the query runs before the widget is fully composed or if it's being removed.
184
+ logging .debug (f"ChatMessage (ID: { self .id } , Role: { self .role } ): QueryError in watch__generation_complete_internal: { qe } . Widget might not be fully ready or is not an AI message with these components." )
185
+ except Exception as e :
186
+ logging .error (f"Error in watch__generation_complete_internal for ChatMessage (ID: { self .id } ): { e } " , exc_info = True )
187
+ else : # Not an AI message
188
+ try : # Ensure continue button is hidden for non-AI messages if it somehow got queried
189
+ continue_button = self .query_one ("#continue-response-button" , Button )
190
+ continue_button .display = False
191
+ except QueryError :
192
+ pass # Expected for non-AI messages as the button isn't composed.
193
+
157
194
158
195
def mark_generation_complete (self ):
196
+ """
197
+ Marks the AI message generation as complete.
198
+ This will trigger the watcher for _generation_complete_internal to update UI.
199
+ """
159
200
if self .has_class ("-ai" ):
160
- self .generation_complete = True
161
- actions_container = self .query_one (".message-actions" )
162
- actions_container .remove_class ("-generating" )
163
- # Ensure it's displayed if CSS might still hide it via other means,
164
- # though removing '-generating' should be enough if the CSS is specific.
165
- actions_container .styles .display = "block" # or "flex" if it's a flex container
201
+ self ._generation_complete_internal = True
202
+
203
+ def on_mount (self ) -> None :
204
+ """Ensure initial state of continue button and actions bar is correct after mounting."""
205
+ # Trigger the watcher logic based on the initial state.
206
+ self .watch__generation_complete_internal (self ._generation_complete_internal )
207
+
208
+ def update_message_chunk (self , chunk : str ):
209
+ """Appends a chunk of text to an AI message during streaming."""
210
+ # This method is called by handle_streaming_chunk.
211
+ # The _generation_complete_internal should be False during streaming.
212
+ if self .has_class ("-ai" ) and not self ._generation_complete_internal :
213
+ # The static_text_widget.update is handled in handle_streaming_chunk
214
+ # This method primarily updates the internal message_text.
215
+ self .message_text += chunk
216
+ # If called at other times, ensure it doesn't break if static_text_widget not found.
217
+ # For streaming, handle_streaming_chunk updates the Static widget directly.
166
218
167
219
#
168
220
#
0 commit comments