diff --git a/pyproject.toml b/pyproject.toml index 95290f0..2c70ad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-dev" -version = "0.0.9" +version = "0.0.10" description = "UiPath Developer Console" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/dev/ui/panels/chat_panel.py b/src/uipath/dev/ui/panels/chat_panel.py index 30d9094..d3b7966 100644 --- a/src/uipath/dev/ui/panels/chat_panel.py +++ b/src/uipath/dev/ui/panels/chat_panel.py @@ -1,6 +1,7 @@ """Chat panel for displaying and interacting with chat messages.""" import time +from collections import deque from textual.app import ComposeResult from textual.containers import Container, Vertical, VerticalScroll @@ -12,6 +13,13 @@ from uipath.dev.models import ChatMessage, ExecutionRun +# Tunables for streaming performance +STREAM_MIN_INTERVAL = 0.08 # seconds between updates while streaming +STREAM_MIN_DELTA_CHARS = 8 # min new chars before we bother updating + +# Limit how many message widgets we keep mounted to avoid DOM explosion. +MAX_WIDGETS = 20 + class Prompt(Markdown): """User prompt message bubble.""" @@ -36,12 +44,18 @@ class ChatPanel(Container): _chat_widgets: dict[str, Markdown] _last_update_time: dict[str, float] + _last_content: dict[str, str] + _chat_view: VerticalScroll | None + _chat_order: deque[str] def __init__(self, **kwargs): """Initialize the chat panel.""" super().__init__(**kwargs) self._chat_widgets = {} self._last_update_time = {} + self._last_content = {} + self._chat_view = None + self._chat_order = deque() def compose(self) -> ComposeResult: """Compose the UI layout.""" @@ -52,12 +66,19 @@ def compose(self) -> ComposeResult: id="chat-input", ) - def update_messages(self, run: ExecutionRun) -> None: + def on_mount(self) -> None: + """Called when the panel is mounted.""" + self._chat_view = self.query_one("#chat-view", VerticalScroll) + + def refresh_messages(self, run: ExecutionRun) -> None: """Update the chat panel with messages from the given execution run.""" - chat_view = self.query_one("#chat-view") - chat_view.remove_children() + assert self._chat_view is not None + + self._chat_view.remove_children() self._chat_widgets.clear() self._last_update_time.clear() + self._last_content.clear() + self._chat_order.clear() for chat_msg in run.messages: self.add_chat_message( @@ -65,7 +86,8 @@ def update_messages(self, run: ExecutionRun) -> None: auto_scroll=False, ) - chat_view.scroll_end(animate=False) + # For a fresh run, always show the latest messages + self._chat_view.scroll_end(animate=False) def add_chat_message( self, @@ -73,13 +95,17 @@ def add_chat_message( auto_scroll: bool = True, ) -> None: """Add or update a chat message bubble.""" - chat_view = self.query_one("#chat-view") + assert self._chat_view is not None + chat_view = self._chat_view - message = chat_msg.message + should_autoscroll = auto_scroll and not chat_view.is_vertical_scrollbar_grabbed + message = chat_msg.message if message is None: return + message_id = message.message_id + widget_cls: type[Prompt] | type[Response] | type[Tool] if message.role == "user": widget_cls = Prompt @@ -114,27 +140,82 @@ def add_chat_message( content = "\n\n".join(content_lines) - existing = self._chat_widgets.get(message.message_id) + prev_content = self._last_content.get(message_id) + if prev_content is not None and content == prev_content: + # We already rendered this exact content, no need to touch the UI. + return + + existing = self._chat_widgets.get(message_id) now = time.monotonic() - last_update = self._last_update_time.get(message.message_id, 0.0) + last_update = self._last_update_time.get(message_id, 0.0) if existing: - event = chat_msg.event - should_update = ( - event - and event.exchange - and event.exchange.message - and event.exchange.message.end is not None - ) - if should_update or now - last_update > 0.15: + prev_content_len = len(prev_content) if prev_content is not None else 0 + delta_len = len(content) - prev_content_len + + def should_update() -> bool: + event = chat_msg.event + finished = ( + event + and event.exchange + and event.exchange.message + and event.exchange.message.end is not None + ) + + if finished: + # Always paint the final state immediately. + return True + + # Throttle streaming: require both some time and a minimum delta size. + if now - last_update < STREAM_MIN_INTERVAL: + return False + + # First streaming chunk for this message: allow update. + if prev_content is None: + return True + + if delta_len < STREAM_MIN_DELTA_CHARS: + return False + + return True + + if not should_update(): + return + + # Fast path: message is growing by appending new text. + if ( + isinstance(existing, Markdown) + and prev_content is not None + and content.startswith(prev_content) + ): + delta = content[len(prev_content) :] + if delta: + # Streaming update: only append the new portion. + existing.append(delta) + else: + # Fallback for non-monotonic changes: full update. existing.update(content) - self._last_update_time[message.message_id] = now - if auto_scroll: - chat_view.scroll_end(animate=False) + + self._last_content[message_id] = content + self._last_update_time[message_id] = now + else: + # First time we see this message: create a new widget. widget_instance = widget_cls(content) chat_view.mount(widget_instance) - self._chat_widgets[message.message_id] = widget_instance - self._last_update_time[message.message_id] = now - if auto_scroll: - chat_view.scroll_end(animate=False) + self._chat_widgets[message_id] = widget_instance + self._last_update_time[message_id] = now + self._last_content[message_id] = content + self._chat_order.append(message_id) + + # Prune oldest widgets to keep DOM size bounded + if len(self._chat_order) > MAX_WIDGETS: + oldest_id = self._chat_order.popleft() + old_widget = self._chat_widgets.pop(oldest_id, None) + self._last_update_time.pop(oldest_id, None) + self._last_content.pop(oldest_id, None) + if old_widget is not None: + old_widget.remove() + + if should_autoscroll: + chat_view.scroll_end(animate=False) diff --git a/src/uipath/dev/ui/panels/run_details_panel.py b/src/uipath/dev/ui/panels/run_details_panel.py index 7b2f7fc..977027b 100644 --- a/src/uipath/dev/ui/panels/run_details_panel.py +++ b/src/uipath/dev/ui/panels/run_details_panel.py @@ -1,5 +1,7 @@ """Panel for displaying execution run details, traces, and logs.""" +from typing import Any + from textual.app import ComposeResult from textual.containers import Container, Horizontal, Vertical from textual.reactive import reactive @@ -84,6 +86,11 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self.span_tree_nodes = {} self.current_run = None + self._chat_panel: ChatPanel | None = None + self._spans_tree: Tree[Any] | None = None + self._logs: RichLog | None = None + self._details: RichLog | None = None + self._debug_controls: Container | None = None def compose(self) -> ComposeResult: """Compose the UI layout.""" @@ -140,6 +147,14 @@ def compose(self) -> ComposeResult: "⏹ Stop", id="debug-stop-btn", variant="error", classes="action-btn" ) + def on_mount(self) -> None: + """Cache frequently used child widgets after mount.""" + self._chat_panel = self.query_one("#chat-panel", ChatPanel) + self._spans_tree = self.query_one("#spans-tree", Tree) + self._logs = self.query_one("#logs-log", RichLog) + self._details = self.query_one("#run-details-log", RichLog) + self._debug_controls = self.query_one("#debug-controls", Container) + def watch_current_run( self, old_value: ExecutionRun | None, new_value: ExecutionRun | None ): @@ -155,12 +170,13 @@ def update_run(self, run: ExecutionRun): def show_run(self, run: ExecutionRun): """Display traces and logs for a specific run.""" + assert self._logs is not None + self._show_run_details(run) self._show_run_chat(run) - logs_log = self.query_one("#logs-log", RichLog) - logs_log.clear() + self._logs.clear() for log in run.logs: self.add_log(log) @@ -173,14 +189,15 @@ def switch_tab(self, tab_id: str) -> None: def update_debug_controls_visibility(self, run: ExecutionRun): """Show or hide debug controls based on whether run is in debug mode.""" - debug_controls = self.query_one("#debug-controls", Container) + assert self._debug_controls is not None + if run.mode == ExecutionMode.DEBUG: - debug_controls.remove_class("hidden") + self._debug_controls.remove_class("hidden") is_enabled = run.status == "suspended" - for button in debug_controls.query(Button): + for button in self._debug_controls.query(Button): button.disabled = not is_enabled else: - debug_controls.add_class("hidden") + self._debug_controls.add_class("hidden") def _flatten_values(self, value: object, prefix: str = "") -> list[str]: """Flatten nested dict/list structures into dot-notation paths.""" @@ -237,13 +254,14 @@ def _write_block( def _show_run_details(self, run: ExecutionRun): """Display detailed information about the run in the Details tab.""" + assert self._details is not None + self.update_debug_controls_visibility(run) - run_details_log = self.query_one("#run-details-log", RichLog) - run_details_log.clear() + self._details.clear() - run_details_log.write(f"[bold cyan]Run ID: {run.id}[/bold cyan]") - run_details_log.write("") + self._details.write(f"[bold cyan]Run ID: {run.id}[/bold cyan]") + self._details.write("") status_color_map = { "started": "blue", @@ -254,68 +272,60 @@ def _show_run_details(self, run: ExecutionRun): } status = getattr(run, "status", "unknown") color = status_color_map.get(status.lower(), "white") - run_details_log.write( - f"[bold]Status:[/bold] [{color}]{status.upper()}[/{color}]" - ) + self._details.write(f"[bold]Status:[/bold] [{color}]{status.upper()}[/{color}]") if hasattr(run, "start_time") and run.start_time: - run_details_log.write( + self._details.write( f"[bold]Started:[/bold] [dim]{run.start_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}[/dim]" ) if hasattr(run, "end_time") and run.end_time: - run_details_log.write( + self._details.write( f"[bold]Ended:[/bold] [dim]{run.end_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}[/dim]" ) - if hasattr(run, "duration_ms") and run.duration_ms is not None: - run_details_log.write( - f"[bold]Duration:[/bold] [yellow]{run.duration_ms:.2f}ms[/yellow]" - ) - elif ( + if ( hasattr(run, "start_time") and hasattr(run, "end_time") and run.start_time and run.end_time ): duration = (run.end_time - run.start_time).total_seconds() * 1000 - run_details_log.write( + self._details.write( f"[bold]Duration:[/bold] [yellow]{duration:.2f}ms[/yellow]" ) - run_details_log.write("") + self._details.write("") if hasattr(run, "input_data"): - self._write_block(run_details_log, "Input", run.input_data, style="green") + self._write_block(self._details, "Input", run.input_data, style="green") if hasattr(run, "resume_data") and run.resume_data: - self._write_block(run_details_log, "Resume", run.resume_data, style="green") + self._write_block(self._details, "Resume", run.resume_data, style="green") if hasattr(run, "output_data"): - self._write_block( - run_details_log, "Output", run.output_data, style="magenta" - ) + self._write_block(self._details, "Output", run.output_data, style="magenta") if hasattr(run, "error") and run.error: - run_details_log.write("[bold red]ERROR:[/bold red]") - run_details_log.write("[dim]" + "=" * 50 + "[/dim]") + self._details.write("[bold red]ERROR:[/bold red]") + self._details.write("[dim]" + "=" * 50 + "[/dim]") if run.error.code: - run_details_log.write(f"[red]Code: {run.error.code}[/red]") - run_details_log.write(f"[red]Title: {run.error.title}[/red]") - run_details_log.write(f"[red]\n{run.error.detail}[/red]") - run_details_log.write("") + self._details.write(f"[red]Code: {run.error.code}[/red]") + self._details.write(f"[red]Title: {run.error.title}[/red]") + self._details.write(f"[red]\n{run.error.detail}[/red]") + self._details.write("") def _show_run_chat(self, run: ExecutionRun) -> None: - chat_panel = self.query_one("#chat-panel", ChatPanel) - chat_panel.update_messages(run) + assert self._chat_panel is not None + + self._chat_panel.refresh_messages(run) def _rebuild_spans_tree(self): """Rebuild the spans tree from current run's traces.""" - spans_tree = self.query_one("#spans-tree", Tree) - if spans_tree is None or spans_tree.root is None: + if self._spans_tree is None or self._spans_tree.root is None: return - spans_tree.root.remove_children() + self._spans_tree.root.remove_children() self.span_tree_nodes.clear() @@ -325,12 +335,13 @@ def _rebuild_spans_tree(self): self._build_spans_tree(self.current_run.traces) # Expand the root "Trace" node - spans_tree.root.expand() + self._spans_tree.root.expand() def _build_spans_tree(self, trace_messages: list[TraceMessage]): """Build the spans tree from trace messages.""" - spans_tree = self.query_one("#spans-tree", Tree) - root = spans_tree.root + assert self._spans_tree is not None + + root = self._spans_tree.root # Filter out spans without parents (artificial root spans) spans_by_id = { @@ -389,8 +400,7 @@ def _add_span_with_children( def on_tree_node_selected(self, event: Tree.NodeSelected[str]) -> None: """Handle span selection in the tree.""" # Check if this is our spans tree - spans_tree = self.query_one("#spans-tree", Tree) - if event.control != spans_tree: + if event.control != self._spans_tree: return # Get the selected span data @@ -422,10 +432,11 @@ def add_chat_message( chat_msg: ChatMessage, ) -> None: """Add a chat message to the display.""" + assert self._chat_panel is not None + if not self.current_run or chat_msg.run_id != self.current_run.id: return - chat_panel = self.query_one("#chat-panel", ChatPanel) - chat_panel.add_chat_message(chat_msg) + self._chat_panel.add_chat_message(chat_msg) def add_trace(self, trace_msg: TraceMessage): """Add trace to current run if it matches.""" @@ -437,6 +448,8 @@ def add_trace(self, trace_msg: TraceMessage): def add_log(self, log_msg: LogMessage): """Add log to current run if it matches.""" + assert self._logs is not None + if not self.current_run or log_msg.run_id != self.current_run.id: return @@ -453,26 +466,25 @@ def add_log(self, log_msg: LogMessage): timestamp_str = log_msg.timestamp.strftime("%H:%M:%S") level_short = log_msg.level[:4].upper() - logs_log = self.query_one("#logs-log", RichLog) if isinstance(log_msg.message, str): log_text = ( f"[dim]{timestamp_str}[/dim] " f"[{color}]{level_short}[/{color}] " f"{log_msg.message}" ) - logs_log.write(log_text) + self._logs.write(log_text) else: - logs_log.write(log_msg.message) + self._logs.write(log_msg.message) def clear_display(self): """Clear both traces and logs display.""" - run_details_log = self.query_one("#run-details-log", RichLog) - logs_log = self.query_one("#logs-log", RichLog) - spans_tree = self.query_one("#spans-tree", Tree) + assert self._details is not None + assert self._logs is not None + assert self._spans_tree is not None - run_details_log.clear() - logs_log.clear() - spans_tree.clear() + self._details.clear() + self._logs.clear() + self._spans_tree.clear() self.current_run = None self.span_tree_nodes.clear() diff --git a/uv.lock b/uv.lock index 7bd2132..9ea3232 100644 --- a/uv.lock +++ b/uv.lock @@ -1002,7 +1002,7 @@ wheels = [ [[package]] name = "uipath-dev" -version = "0.0.9" +version = "0.0.10" source = { editable = "." } dependencies = [ { name = "pyperclip" },