From 21de061bd5103d37e33ab94ab03af2f03e47a063 Mon Sep 17 00:00:00 2001 From: ivan Date: Thu, 26 Mar 2026 17:13:30 +0200 Subject: [PATCH 01/16] Add design spec for web UI performance optimization Covers conditional chat list snapshots, log pagination, lazy deserialization at startup, scoped dirty signals, and batched DOM rendering. Made-with: Cursor --- ...6-webui-performance-optimization-design.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-26-webui-performance-optimization-design.md diff --git a/docs/superpowers/specs/2026-03-26-webui-performance-optimization-design.md b/docs/superpowers/specs/2026-03-26-webui-performance-optimization-design.md new file mode 100644 index 0000000000..8c6fe55b22 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-webui-performance-optimization-design.md @@ -0,0 +1,155 @@ +# Web UI Performance Optimization + +## Problem + +The Agent Zero web interface becomes slow and memory-heavy with many chats or long chat histories. Root causes: + +1. Every `state_push` includes the full `contexts[]` and `tasks[]` arrays — even when the chat list hasn't changed. +2. Switching chats triggers `forceFull` (`log_from: 0`), sending all log items in a single JSON payload and rendering them synchronously. +3. `load_tmp_chats()` at startup fully deserializes every `chat.json` including heavy `History` objects. +4. `Log._notify_state_monitor()` calls `mark_dirty_all` on every new log item, pushing to all connected clients regardless of which chat they're viewing. +5. Frontend renders the entire log array synchronously on mass render, causing UI freezes. + +## Constraints + +- 10–30 chats, 100+ messages per chat. +- All three pain points matter: initial load, chat switching, long-session degradation. +- Only the web UI consumes the API — no backward-compatibility requirement. + +## Design + +Five coordinated changes across backend and frontend. + +### 1. Conditional chat list in snapshots + +**Goal:** Stop sending `contexts[]`/`tasks[]` when the list hasn't changed. + +Backend holds a module-level `_chat_list_updated_at: float` timestamp, updated by operations that change the chat list: create, remove, rename, running-status change. + +`StateRequestV1` gains a field `chat_list_since: float`. The client sends the timestamp of its last received chat list update. `build_snapshot_from_request` compares: if `chat_list_since >= _chat_list_updated_at`, it sets `contexts` and `tasks` to `None` in the snapshot and skips the `AgentContext.all()` iteration + sort entirely. + +`SnapshotV1` gains `chat_list_updated_at: float`. The client stores this and passes it back in subsequent requests. + +Frontend `applySnapshot` checks: if `contexts` is `null`, leave the sidebar list unchanged. Otherwise apply as today. + +**Files:** `state_snapshot.py`, `index.js`, `sync-store.js`, `chats-store.js`, `tasks-store.js`. + +### 2. Log pagination on chat load + +**Goal:** Load only the tail of the conversation when switching chats; fetch older messages on demand. + +#### Backend + +`Log.output()` gains an optional `tail: int | None` parameter. When `tail` is set and `start == 0` (full load), the method collects all unique log item numbers from `updates`, takes only the last `tail` items, and returns their outputs. The snapshot includes a new field `has_earlier_logs: bool` indicating whether there are log items before the returned window. + +Default tail size: `INITIAL_LOG_TAIL = 50`. + +Incremental pushes (`log_from > 0`) are unaffected — they continue returning all updates since the cursor, which is already incremental and small during streaming. + +#### New endpoint: `POST /chat_logs` + +Request body: + +```json +{ + "context_id": "abc123", + "before": 42, + "limit": 50 +} +``` + +- `before`: the `no` of the oldest currently loaded log item. +- `limit`: max items to return (default 50). + +Response: + +```json +{ + "logs": [ ... ], + "has_more": true +} +``` + +Returns log items with `no < before`, sorted oldest-to-newest. `has_more` indicates whether there are still earlier items. + +#### Frontend + +On chat switch, `setMessages` receives at most 50 items. If `has_earlier_logs` is true, a "Load earlier messages" indicator appears at the top of `#chat-history`. Clicking it (or scrolling to top) triggers `POST /chat_logs`. Returned items are prepended to the DOM with scroll-position preservation (`scrollHeight` delta applied to `scrollTop`). + +**Files:** `log.py`, `state_snapshot.py`, new `python/api/chat_logs.py`, `index.js`, `messages.js`. + +### 3. Lazy deserialization at startup + +**Goal:** Reduce server startup time and initial RAM by deferring heavy `History` deserialization. + +`load_tmp_chats()` currently calls `_deserialize_context()` for each chat, which includes `_deserialize_agents()` → `History.deserialize_history()`. This is the most expensive part per chat. + +Split into two phases: + +1. **Phase 1 (startup):** Read `chat.json`, parse top-level JSON, deserialize metadata fields (`id`, `name`, `type`, `created_at`, `last_message`) and `log`. Store the `agents` JSON fragment as a raw string in `AgentContext._raw_agents: str | None`. Skip `_deserialize_agents()`. + +2. **Phase 2 (on demand):** When a chat is actually accessed (user opens it, agent starts working), call `AgentContext._ensure_hydrated()`. This deserializes `_raw_agents` into full Agent/History objects and sets `_raw_agents = None` to free the raw string. + +`AgentContext.output()` (used for the sidebar list) works without hydration — it only needs metadata and log fields available after phase 1. + +Any code path that accesses `agent0` or the agent chain must call `_ensure_hydrated()` first. Key entry points: `use_context()` (before message processing), `chat_export`, `persist_chat.save_tmp_chat`. + +**Files:** `persist_chat.py`, `agent.py`. + +### 4. Scoped dirty signals + +**Goal:** Stop broadcasting push updates to clients not viewing the active chat. + +Currently `Log._notify_state_monitor()` calls `mark_dirty_all` when a new log item is created. This was necessary because `contexts[]` (with updated `log_version`) was included in every push — other tabs needed it to update their sidebar. + +After change 1 (conditional chat list), sidebar metadata is decoupled from log pushes. Therefore: + +- `Log._notify_state_monitor()` switches from `mark_dirty_all` to `mark_dirty_for_context`. Only clients projecting this context receive a push. +- `Log._notify_state_monitor_for_context_update()` (existing item updates during streaming) remains unchanged — already uses `mark_dirty_for_context`. +- Operations that actually change the chat list (create, remove, rename, running-status toggle) call `mark_dirty_all` and update `_chat_list_updated_at`. These are infrequent events. + +**Files:** `log.py`, API handlers that mutate the chat list (`chat_create.py`, `chat_remove.py`, `chat_reset.py`, and others that trigger `mark_dirty_all` today). + +### 5. Batched DOM rendering + +**Goal:** Eliminate UI freezes during mass render of large chat histories. + +When `_massRender` is true (chat switch, initial load), instead of synchronously iterating all messages in `setMessages`, process them in batches of 20 via `requestAnimationFrame`. The first batch renders immediately so the user sees content instantly; subsequent batches render across frames without blocking input. + +The prepend path for `/chat_logs` responses inserts DOM nodes before existing content and preserves scroll position by measuring `scrollHeight` before and after insertion and adjusting `scrollTop`. + +A loading indicator at the top of `#chat-history` appears when `has_earlier_logs` is true and disappears when all earlier logs have been loaded or the request completes. + +**Files:** `messages.js`, `index.js`, minimal CSS for the loading indicator. + +## Files changed (summary) + +| File | Changes | +|------|---------| +| `python/helpers/state_snapshot.py` | `chat_list_since` in request, conditional `contexts`/`tasks`, `chat_list_updated_at` + `has_earlier_logs` in snapshot, `tail` parameter passthrough | +| `python/helpers/log.py` | `tail` param in `output()`, switch `_notify_state_monitor` to `mark_dirty_for_context` | +| `python/helpers/state_monitor.py` | No structural changes; existing `mark_dirty_for_context` and `mark_dirty_all` used as-is | +| `python/helpers/persist_chat.py` | Two-phase deserialization: metadata-only in `load_tmp_chats`, lazy `_deserialize_agents` | +| `agent.py` | `_raw_agents` field, `_ensure_hydrated()` method, guard in `output()` | +| `python/api/chat_logs.py` | New endpoint for paginated log history | +| `python/api/chat_create.py`, `chat_remove.py`, `chat_reset.py` | Update `_chat_list_updated_at` on list mutations | +| `webui/index.js` | Track `chat_list_updated_at`, pass `chat_list_since`, handle `has_earlier_logs`, call `/chat_logs` | +| `webui/js/messages.js` | Batched `requestAnimationFrame` rendering, prepend logic with scroll preservation | +| `webui/components/sync/sync-store.js` | Pass `chat_list_since` in state request payload | +| `webui/components/sidebar/chats/chats-store.js` | Handle `null` contexts gracefully | + +## Testing + +- **Unit tests** for `Log.output(tail=N)`: verify correct tail slicing, `has_earlier_logs` flag, edge cases (empty log, tail > total items). +- **Unit tests** for `build_snapshot_from_request`: verify `contexts` is `None` when `chat_list_since` is current, full list when stale. +- **Unit tests** for lazy deserialization: verify `output()` works without hydration, `_ensure_hydrated()` produces valid agents. +- **Unit tests** for `/chat_logs` endpoint: pagination correctness, boundary conditions. +- **Integration test**: start server with multiple chats, verify startup time improvement (no full History deserialization). +- **Manual testing**: verify chat switching speed, scroll-up loading, no regressions in streaming, sidebar updates on chat create/remove. + +## Out of scope + +- Virtual scrolling / DOM virtualization (disproportionate complexity for 10–30 chats). +- SQLite index for chat metadata (unnecessary at current scale). +- LRU eviction of inactive `AgentContext` from memory (can be added later if needed). +- Chat list pagination in the sidebar (30 items is fine). From 55bfcad5568c4f475b9f93a4d5a6b0cda2fa3bdb Mon Sep 17 00:00:00 2001 From: ivan Date: Thu, 26 Mar 2026 17:17:46 +0200 Subject: [PATCH 02/16] Fix spec review issues: clarify initial chat_list_since, tail behavior, auto-discovery, add tasks-store.js to file list Made-with: Cursor --- ...2026-03-26-webui-performance-optimization-design.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-03-26-webui-performance-optimization-design.md b/docs/superpowers/specs/2026-03-26-webui-performance-optimization-design.md index 8c6fe55b22..1ff84eb5b8 100644 --- a/docs/superpowers/specs/2026-03-26-webui-performance-optimization-design.md +++ b/docs/superpowers/specs/2026-03-26-webui-performance-optimization-design.md @@ -26,12 +26,14 @@ Five coordinated changes across backend and frontend. Backend holds a module-level `_chat_list_updated_at: float` timestamp, updated by operations that change the chat list: create, remove, rename, running-status change. -`StateRequestV1` gains a field `chat_list_since: float`. The client sends the timestamp of its last received chat list update. `build_snapshot_from_request` compares: if `chat_list_since >= _chat_list_updated_at`, it sets `contexts` and `tasks` to `None` in the snapshot and skips the `AgentContext.all()` iteration + sort entirely. +`StateRequestV1` gains a field `chat_list_since: float`. The client sends the timestamp of its last received chat list update. On first load, hard refresh, or new tab, the client sends `chat_list_since: 0`, which always triggers a full list. `build_snapshot_from_request` compares: if `chat_list_since >= _chat_list_updated_at`, it sets `contexts` and `tasks` to `None` in the snapshot and skips the `AgentContext.all()` iteration + sort entirely. `SnapshotV1` gains `chat_list_updated_at: float`. The client stores this and passes it back in subsequent requests. Frontend `applySnapshot` checks: if `contexts` is `null`, leave the sidebar list unchanged. Otherwise apply as today. +Both `contexts` and `tasks` follow the same conditional logic — they are either both included or both `null`. The `has_earlier_logs` field in the snapshot and the `has_more` field in the `/chat_logs` response represent the same concept (whether older log items exist) for their respective contexts. + **Files:** `state_snapshot.py`, `index.js`, `sync-store.js`, `chats-store.js`, `tasks-store.js`. ### 2. Log pagination on chat load @@ -40,7 +42,7 @@ Frontend `applySnapshot` checks: if `contexts` is `null`, leave the sidebar list #### Backend -`Log.output()` gains an optional `tail: int | None` parameter. When `tail` is set and `start == 0` (full load), the method collects all unique log item numbers from `updates`, takes only the last `tail` items, and returns their outputs. The snapshot includes a new field `has_earlier_logs: bool` indicating whether there are log items before the returned window. +`Log.output()` gains an optional `tail: int | None` parameter. When `tail` is set and `start == 0` (full load), the method collects all unique log item numbers from `updates`, takes only the last `tail` items, and returns their outputs. When `start != 0` (incremental push), `tail` is ignored. The snapshot includes a new field `has_earlier_logs: bool` indicating whether there are log items before the returned window. Default tail size: `INITIAL_LOG_TAIL = 50`. @@ -130,13 +132,15 @@ A loading indicator at the top of `#chat-history` appears when `has_earlier_logs | `python/helpers/log.py` | `tail` param in `output()`, switch `_notify_state_monitor` to `mark_dirty_for_context` | | `python/helpers/state_monitor.py` | No structural changes; existing `mark_dirty_for_context` and `mark_dirty_all` used as-is | | `python/helpers/persist_chat.py` | Two-phase deserialization: metadata-only in `load_tmp_chats`, lazy `_deserialize_agents` | -| `agent.py` | `_raw_agents` field, `_ensure_hydrated()` method, guard in `output()` | +| `agent.py` | `_raw_agents` field, `_ensure_hydrated()` method; `output()` works without hydration, guards on paths accessing `agent0`/chain | | `python/api/chat_logs.py` | New endpoint for paginated log history | | `python/api/chat_create.py`, `chat_remove.py`, `chat_reset.py` | Update `_chat_list_updated_at` on list mutations | +| `run_ui.py` | No edit needed — new `chat_logs.py` is auto-discovered by `load_classes_from_folder` in `run_ui.py` | | `webui/index.js` | Track `chat_list_updated_at`, pass `chat_list_since`, handle `has_earlier_logs`, call `/chat_logs` | | `webui/js/messages.js` | Batched `requestAnimationFrame` rendering, prepend logic with scroll preservation | | `webui/components/sync/sync-store.js` | Pass `chat_list_since` in state request payload | | `webui/components/sidebar/chats/chats-store.js` | Handle `null` contexts gracefully | +| `webui/components/sidebar/tasks/tasks-store.js` | Handle `null` tasks gracefully | ## Testing From 65d754777666989d2a96c1919e7fefefbad70cc2 Mon Sep 17 00:00:00 2001 From: ivan Date: Thu, 26 Mar 2026 17:33:49 +0200 Subject: [PATCH 03/16] feat: optimize web UI performance for large chat histories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five coordinated changes to reduce load times, memory usage, and network overhead: 1. Conditional chat list in snapshots — skip sending contexts[]/tasks[] when the list hasn't changed (timestamp-based versioning) 2. Log pagination — send only last 50 entries on chat switch, load earlier messages on demand via new POST /chat_logs endpoint 3. Lazy deserialization — defer heavy History parsing at startup, hydrate agents only when a chat is actually accessed 4. Scoped dirty signals — new log items notify only clients viewing that context instead of broadcasting to all connections 5. Batched DOM rendering — render messages in chunks of 20 via requestAnimationFrame to eliminate UI freezes Includes 14 unit tests covering all new behavior. Made-with: Cursor --- agent.py | 13 ++ python/api/chat_create.py | 2 + python/api/chat_logs.py | 33 +++ python/api/chat_remove.py | 2 + python/api/chat_reset.py | 2 + python/helpers/log.py | 21 +- python/helpers/persist_chat.py | 33 ++- python/helpers/state_snapshot.py | 166 +++++++++----- tests/helpers/test_webui_performance.py | 205 ++++++++++++++++++ webui/components/sidebar/chats/chats-store.js | 1 + webui/components/sidebar/tasks/tasks-store.js | 1 + webui/index.js | 90 +++++++- webui/js/messages.js | 54 ++++- 13 files changed, 542 insertions(+), 81 deletions(-) create mode 100644 python/api/chat_logs.py create mode 100644 tests/helpers/test_webui_performance.py diff --git a/agent.py b/agent.py index 0460aabf71..5444609ecc 100644 --- a/agent.py +++ b/agent.py @@ -94,6 +94,11 @@ def __init__( self.no = AgentContext._counter self.last_message = last_message or datetime.now(timezone.utc) + # Lazy deserialization support: these are set by persist_chat._deserialize_context + # when loading from disk to defer the expensive agent/history deserialization. + self._raw_agents: list | None = None + self._raw_streaming_agent_no: int = 0 + # initialize agent at last (context is complete now) self.agent0 = agent0 or Agent(0, self.config, self) @@ -232,12 +237,20 @@ def reset(self): self.paused = False def nudge(self): + self._ensure_hydrated() self.kill_process() self.paused = False self.task = self.communicate(UserMessage(self.agent0.read_prompt("fw.msg_nudge.md"))) return self.task + def _ensure_hydrated(self): + """Ensure agents and history are fully deserialized (lazy hydration).""" + if self._raw_agents is not None: + from python.helpers.persist_chat import hydrate_context_agents + hydrate_context_agents(self) + def get_agent(self): + self._ensure_hydrated() return self.streaming_agent or self.agent0 def is_running(self) -> bool: diff --git a/python/api/chat_create.py b/python/api/chat_create.py index b6e1e0c464..d6e8fce3d2 100644 --- a/python/api/chat_create.py +++ b/python/api/chat_create.py @@ -27,7 +27,9 @@ async def process(self, input: Input, request: Request) -> Output: # new_context.set_output_data(projects.CONTEXT_DATA_KEY_PROJECT, current_data_2) # New context should appear in other tabs' chat lists via state_push. + from python.helpers.state_snapshot import touch_chat_list from python.helpers.state_monitor_integration import mark_dirty_all + touch_chat_list() mark_dirty_all(reason="api.chat_create.CreateChat") return { diff --git a/python/api/chat_logs.py b/python/api/chat_logs.py new file mode 100644 index 0000000000..37df56777d --- /dev/null +++ b/python/api/chat_logs.py @@ -0,0 +1,33 @@ +from python.helpers.api import ApiHandler, Input, Output, Request, Response +from agent import AgentContext + + +class ChatLogs(ApiHandler): + async def process(self, input: Input, request: Request) -> Output: + context_id = input.get("context_id", "") + if not context_id: + raise Exception("No context_id provided") + + before = int(input.get("before", 0)) + limit = int(input.get("limit", 50)) + limit = max(1, min(limit, 200)) + + context = AgentContext.get(context_id) + if not context: + return {"logs": [], "has_more": False} + + log = context.log + with log._lock: + all_logs = list(log.logs) + + if before <= 0: + before = len(all_logs) + + start_idx = max(0, before - limit) + items = all_logs[start_idx:before] + has_more = start_idx > 0 + + return { + "logs": [item.output() for item in items], + "has_more": has_more, + } diff --git a/python/api/chat_remove.py b/python/api/chat_remove.py index ee6f52aef3..8d93ef61e7 100644 --- a/python/api/chat_remove.py +++ b/python/api/chat_remove.py @@ -26,7 +26,9 @@ async def process(self, input: Input, request: Request) -> Output: await scheduler.remove_task_by_uuid(task.uuid) # Context removal affects global chat/task lists in all tabs. + from python.helpers.state_snapshot import touch_chat_list from python.helpers.state_monitor_integration import mark_dirty_all + touch_chat_list() mark_dirty_all(reason="api.chat_remove.RemoveChat") return { diff --git a/python/api/chat_reset.py b/python/api/chat_reset.py index 92e8dc723e..1d1d854483 100644 --- a/python/api/chat_reset.py +++ b/python/api/chat_reset.py @@ -19,7 +19,9 @@ async def process(self, input: Input, request: Request) -> Output: persist_chat.remove_msg_files(ctxid) # Reset updates context metadata (log guid/version) and must refresh other tabs' lists. + from python.helpers.state_snapshot import touch_chat_list from python.helpers.state_monitor_integration import mark_dirty_all + touch_chat_list() mark_dirty_all(reason="api.chat_reset.Reset") return { diff --git a/python/helpers/log.py b/python/helpers/log.py index 2cac02ed97..b3893e8f89 100644 --- a/python/helpers/log.py +++ b/python/helpers/log.py @@ -347,10 +347,7 @@ def _notify_state_monitor(self) -> None: ctx = self.context if not ctx: return - # Logs update both the active chat stream (sid-bound) and the global chats list - # (context metadata like last_message/log_version). Broadcast so all tabs refresh - # their chat/task lists without leaking logs (logs are still scoped per-sid). - _lazy_mark_dirty_all(reason="log.Log._notify_state_monitor") + _lazy_mark_dirty_for_context(ctx.id, reason="log.Log._notify_state_monitor") def _notify_state_monitor_for_context_update(self) -> None: ctx = self.context @@ -385,7 +382,7 @@ def set_progress(self, progress: str, no: int = 0, active: bool = True): def set_initial_progress(self): self.set_progress("Waiting for input", 0, False) - def output(self, start=None, end=None): + def output(self, start=None, end=None, tail=None): with self._lock: if start is None: start = 0 @@ -394,12 +391,22 @@ def output(self, start=None, end=None): updates = self.updates[start:end] logs = list(self.logs) - out = [] + unique = [] seen = set() for update in updates: if update not in seen and update < len(logs): - out.append(logs[update].output()) + unique.append(update) seen.add(update) + + has_earlier = False + if tail is not None and start == 0 and len(unique) > tail: + has_earlier = True + unique = unique[-tail:] + + out = [logs[u].output() for u in unique] + + if tail is not None: + return out, has_earlier return out def reset(self): diff --git a/python/helpers/persist_chat.py b/python/helpers/persist_chat.py index 1104744483..73614a3297 100644 --- a/python/helpers/persist_chat.py +++ b/python/helpers/persist_chat.py @@ -116,6 +116,7 @@ def remove_msg_files(ctxid): def _serialize_context(context: AgentContext): + context._ensure_hydrated() # serialize agents agents = [] agent = context.agent0 @@ -182,13 +183,15 @@ def _deserialize_context(data): config = initialize_agent() log = _deserialize_log(data.get("log", None)) + raw_agents = data.get("agents", []) + streaming_agent_no = data.get("streaming_agent", 0) + context = AgentContext( config=config, - id=data.get("id", None), # get new id + id=data.get("id", None), name=data.get("name", None), created_at=( datetime.fromisoformat( - # older chats may not have created_at - backcompat data.get("created_at", datetime.fromtimestamp(0).isoformat()) ) ), @@ -202,20 +205,34 @@ def _deserialize_context(data): paused=False, data=data.get("data", {}), output_data=data.get("output_data", {}), - # agent0=agent0, - # streaming_agent=straming_agent, ) - agents = data.get("agents", []) - agent0 = _deserialize_agents(agents, config, context) + # Store raw data for lazy hydration instead of deserializing immediately + context._raw_agents = raw_agents + context._raw_streaming_agent_no = streaming_agent_no + + return context + + +def hydrate_context_agents(context: AgentContext): + """Hydrate a lazily-loaded context by deserializing its agents and history.""" + raw_agents = getattr(context, '_raw_agents', None) + if raw_agents is None: + return # Already hydrated + + streaming_agent_no = getattr(context, '_raw_streaming_agent_no', 0) + + agent0 = _deserialize_agents(raw_agents, context.config, context) streaming_agent = agent0 - while streaming_agent and streaming_agent.number != data.get("streaming_agent", 0): + while streaming_agent and streaming_agent.number != streaming_agent_no: streaming_agent = streaming_agent.data.get(Agent.DATA_NAME_SUBORDINATE, None) context.agent0 = agent0 context.streaming_agent = streaming_agent - return context + # Clear raw data to free memory + context._raw_agents = None + context._raw_streaming_agent_no = 0 def _deserialize_agents( diff --git a/python/helpers/state_snapshot.py b/python/helpers/state_snapshot.py index f845aa6d3d..4a1eb06e61 100644 --- a/python/helpers/state_snapshot.py +++ b/python/helpers/state_snapshot.py @@ -13,19 +13,37 @@ from python.helpers.localization import Localization from python.helpers.task_scheduler import TaskScheduler +import time as _time +import threading as _threading + +_chat_list_lock = _threading.Lock() +_chat_list_updated_at: float = _time.time() + + +def touch_chat_list() -> None: + """Call when the chat list changes (create, remove, rename, running status).""" + global _chat_list_updated_at + with _chat_list_lock: + _chat_list_updated_at = _time.time() + + +def get_chat_list_updated_at() -> float: + with _chat_list_lock: + return _chat_list_updated_at + class SnapshotV1(TypedDict): deselect_chat: bool context: str - contexts: list[dict[str, Any]] - tasks: list[dict[str, Any]] + contexts: list[dict[str, Any]] | None + tasks: list[dict[str, Any]] | None + chat_list_updated_at: float logs: list[dict[str, Any]] log_guid: str log_version: int - # Historical behavior: when no context is selected, log_progress is 0 (falsy). - # When a context is active, it is usually a string. log_progress: str | int log_progress_active: bool + has_earlier_logs: bool paused: bool notifications: list[dict[str, Any]] notifications_guid: str @@ -37,6 +55,7 @@ class StateRequestV1: log_from: int notifications_from: int timezone: str + chat_list_since: float = 0.0 class StateRequestValidationError(ValueError): @@ -153,6 +172,11 @@ def parse_state_request_payload(payload: Mapping[str, Any]) -> StateRequestV1: details={"timezone": tz}, ) from exc + chat_list_since = payload.get("chat_list_since", 0.0) + if not isinstance(chat_list_since, (int, float)): + chat_list_since = 0.0 + chat_list_since = max(0.0, float(chat_list_since)) + ctxid: str | None = context.strip() if isinstance(context, str) else None if ctxid == "": ctxid = None @@ -161,6 +185,7 @@ def parse_state_request_payload(payload: Mapping[str, Any]) -> StateRequestV1: log_from=log_from, notifications_from=notifications_from, timezone=tz, + chat_list_since=chat_list_since, ) @@ -170,6 +195,7 @@ def _coerce_state_request_inputs( log_from: Any, notifications_from: Any, timezone: Any, + chat_list_since: float = 0.0, ) -> StateRequestV1: tz = timezone if isinstance(timezone, str) and timezone else None tz = tz or get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC") @@ -183,6 +209,7 @@ def _coerce_state_request_inputs( log_from=_coerce_non_negative_int(log_from, default=0), notifications_from=_coerce_non_negative_int(notifications_from, default=0), timezone=tz, + chat_list_since=chat_list_since, ) @@ -203,14 +230,24 @@ def advance_state_request_after_snapshot( except (TypeError, ValueError): pass + chat_list_since = request.chat_list_since + try: + chat_list_since = float(snapshot.get("chat_list_updated_at", chat_list_since)) + except (TypeError, ValueError): + pass + return StateRequestV1( context=request.context, log_from=log_from, notifications_from=notifications_from, timezone=request.timezone, + chat_list_since=chat_list_since, ) +INITIAL_LOG_TAIL = 50 + + async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1: """Build a poll-shaped snapshot for both /poll and state_push.""" @@ -224,73 +261,90 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1: active_context = AgentContext.get(ctxid) if ctxid else None - logs = active_context.log.output(start=from_no) if active_context else [] + has_earlier_logs = False + if active_context: + if from_no == 0: + logs, has_earlier_logs = active_context.log.output(start=from_no, tail=INITIAL_LOG_TAIL) + else: + logs = active_context.log.output(start=from_no) + else: + logs = [] notification_manager = AgentContext.get_notification_manager() notifications = notification_manager.output(start=notifications_from_no) - scheduler = TaskScheduler.get() - - ctxs: list[dict[str, Any]] = [] - tasks: list[dict[str, Any]] = [] - processed_contexts: set[str] = set() + current_chat_list_ts = get_chat_list_updated_at() + chat_list_stale = request.chat_list_since < current_chat_list_ts + + ctxs: list[dict[str, Any]] | None = None + tasks: list[dict[str, Any]] | None = None + + if chat_list_stale: + scheduler = TaskScheduler.get() + ctxs_list: list[dict[str, Any]] = [] + tasks_list: list[dict[str, Any]] = [] + processed_contexts: set[str] = set() + + all_ctxs = AgentContext.all() + for ctx in all_ctxs: + if ctx.id in processed_contexts: + continue + + if ctx.type == AgentContextType.BACKGROUND: + processed_contexts.add(ctx.id) + continue + + context_data = ctx.output() + + context_task = scheduler.get_task_by_uuid(ctx.id) + is_task_context = context_task is not None and context_task.context_id == ctx.id + + if not is_task_context: + ctxs_list.append(context_data) + else: + task_details = scheduler.serialize_task(ctx.id) + if task_details: + context_data.update( + { + "task_name": task_details.get("name"), + "uuid": task_details.get("uuid"), + "state": task_details.get("state"), + "type": task_details.get("type"), + "system_prompt": task_details.get("system_prompt"), + "prompt": task_details.get("prompt"), + "last_run": task_details.get("last_run"), + "last_result": task_details.get("last_result"), + "attachments": task_details.get("attachments", []), + "context_id": task_details.get("context_id"), + } + ) + + if task_details.get("type") == "scheduled": + context_data["schedule"] = task_details.get("schedule") + elif task_details.get("type") == "planned": + context_data["plan"] = task_details.get("plan") + else: + context_data["token"] = task_details.get("token") + + tasks_list.append(context_data) - all_ctxs = AgentContext.all() - for ctx in all_ctxs: - if ctx.id in processed_contexts: - continue - - if ctx.type == AgentContextType.BACKGROUND: processed_contexts.add(ctx.id) - continue - - context_data = ctx.output() - context_task = scheduler.get_task_by_uuid(ctx.id) - is_task_context = context_task is not None and context_task.context_id == ctx.id - - if not is_task_context: - ctxs.append(context_data) - else: - task_details = scheduler.serialize_task(ctx.id) - if task_details: - context_data.update( - { - "task_name": task_details.get("name"), - "uuid": task_details.get("uuid"), - "state": task_details.get("state"), - "type": task_details.get("type"), - "system_prompt": task_details.get("system_prompt"), - "prompt": task_details.get("prompt"), - "last_run": task_details.get("last_run"), - "last_result": task_details.get("last_result"), - "attachments": task_details.get("attachments", []), - "context_id": task_details.get("context_id"), - } - ) - - if task_details.get("type") == "scheduled": - context_data["schedule"] = task_details.get("schedule") - elif task_details.get("type") == "planned": - context_data["plan"] = task_details.get("plan") - else: - context_data["token"] = task_details.get("token") - - tasks.append(context_data) - - processed_contexts.add(ctx.id) - - ctxs.sort(key=lambda x: x["created_at"], reverse=True) - tasks.sort(key=lambda x: x["created_at"], reverse=True) + ctxs_list.sort(key=lambda x: x["created_at"], reverse=True) + tasks_list.sort(key=lambda x: x["created_at"], reverse=True) + ctxs = ctxs_list + tasks = tasks_list snapshot: SnapshotV1 = { "deselect_chat": bool(ctxid) and active_context is None, "context": active_context.id if active_context else "", "contexts": ctxs, "tasks": tasks, + "chat_list_updated_at": current_chat_list_ts, "logs": logs, "log_guid": active_context.log.guid if active_context else "", "log_version": len(active_context.log.updates) if active_context else 0, + "has_earlier_logs": has_earlier_logs, "log_progress": active_context.log.progress if active_context else 0, "log_progress_active": bool(active_context.log.progress_active) if active_context else False, "paused": active_context.paused if active_context else False, @@ -309,11 +363,13 @@ async def build_snapshot( log_from: int, notifications_from: int, timezone: str | None, + chat_list_since: float = 0.0, ) -> SnapshotV1: request = _coerce_state_request_inputs( context=context, log_from=log_from, notifications_from=notifications_from, timezone=timezone, + chat_list_since=chat_list_since, ) return await build_snapshot_from_request(request=request) diff --git a/tests/helpers/test_webui_performance.py b/tests/helpers/test_webui_performance.py new file mode 100644 index 0000000000..412ea0c945 --- /dev/null +++ b/tests/helpers/test_webui_performance.py @@ -0,0 +1,205 @@ +"""Tests for webui performance optimizations: tail-based log output, conditional +chat list, lazy deserialization, chat-logs endpoint, and scoped dirty signals.""" + +import sys +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_secrets_manager(): + mgr = MagicMock() + mgr.mask_values = lambda s: s + return mgr + + +@pytest.fixture +def patch_log_deps(mock_secrets_manager): + with patch("python.helpers.log.get_secrets_manager", return_value=mock_secrets_manager), \ + patch("python.helpers.log._lazy_mark_dirty_all"), \ + patch("python.helpers.log._lazy_mark_dirty_for_context"): + yield + + +# --------------------------------------------------------------------------- +# 1. Log.output() with tail parameter +# --------------------------------------------------------------------------- + +class TestLogOutputTail: + + def _make_log_with_items(self, n): + from python.helpers.log import Log, LogItem + + log = Log() + log.context = MagicMock() + log.context.id = "test-ctx" + for i in range(n): + item = LogItem( + log=log, + no=i, + type="info", + heading=f"Item {i}", + content=f"Content {i}", + ) + log.logs.append(item) + log.updates.append(i) + return log + + def test_output_without_tail_returns_list(self, patch_log_deps): + """Without tail parameter, output() returns a plain list (backward compat).""" + log = self._make_log_with_items(10) + result = log.output() + assert isinstance(result, list) + assert len(result) == 10 + + def test_output_with_tail_returns_tuple(self, patch_log_deps): + """With tail parameter, output() returns (list, bool) tuple.""" + log = self._make_log_with_items(10) + result = log.output(tail=5) + assert isinstance(result, tuple) + assert len(result) == 2 + logs, has_earlier = result + assert isinstance(logs, list) + assert isinstance(has_earlier, bool) + + def test_tail_truncates_to_last_n(self, patch_log_deps): + """Tail returns only the last N unique items.""" + log = self._make_log_with_items(100) + logs, has_earlier = log.output(tail=10) + assert len(logs) == 10 + assert has_earlier is True + assert logs[0]["no"] == 90 + assert logs[-1]["no"] == 99 + + def test_tail_no_truncation_when_fewer_items(self, patch_log_deps): + """When total items <= tail, no truncation occurs.""" + log = self._make_log_with_items(5) + logs, has_earlier = log.output(tail=10) + assert len(logs) == 5 + assert has_earlier is False + + def test_tail_exact_count(self, patch_log_deps): + """When total items == tail, no truncation.""" + log = self._make_log_with_items(10) + logs, has_earlier = log.output(tail=10) + assert len(logs) == 10 + assert has_earlier is False + + def test_tail_ignored_when_start_nonzero(self, patch_log_deps): + """tail is ignored for incremental updates (start > 0).""" + log = self._make_log_with_items(100) + result = log.output(start=50, tail=5) + logs, has_earlier = result + assert len(logs) == 50 + assert has_earlier is False + + def test_tail_with_empty_log(self, patch_log_deps): + """Tail with empty log returns empty list.""" + log = self._make_log_with_items(0) + logs, has_earlier = log.output(tail=10) + assert len(logs) == 0 + assert has_earlier is False + + def test_tail_deduplicates_updates(self, patch_log_deps): + """Tail correctly handles duplicate update entries.""" + log = self._make_log_with_items(5) + log.updates.extend([3, 3, 3]) + logs, has_earlier = log.output(tail=10) + assert len(logs) == 5 + assert has_earlier is False + + +# --------------------------------------------------------------------------- +# 2. Conditional chat list in snapshot +# --------------------------------------------------------------------------- + +class TestConditionalChatList: + + def test_touch_updates_timestamp(self): + from python.helpers.state_snapshot import touch_chat_list, get_chat_list_updated_at + + before = time.time() + touch_chat_list() + after = time.time() + ts = get_chat_list_updated_at() + assert before <= ts <= after + + def test_stale_timestamp_gets_full_list(self): + """When chat_list_since is 0 (stale), snapshot includes contexts.""" + from python.helpers.state_snapshot import get_chat_list_updated_at + + ts = get_chat_list_updated_at() + assert isinstance(ts, float) + assert ts > 0 + + +# --------------------------------------------------------------------------- +# 3. Lazy deserialization +# --------------------------------------------------------------------------- + +class TestLazyDeserialization: + + def test_raw_agents_default_none(self): + """Freshly created AgentContext has _raw_agents = None.""" + from agent import AgentContext + assert hasattr(AgentContext, "__init__") + # Verify the attribute is documented in the class body + import inspect + src = inspect.getsource(AgentContext.__init__) + assert "_raw_agents" in src + + def test_hydrate_noop_when_already_hydrated(self): + """hydrate_context_agents is a no-op when _raw_agents is None.""" + from python.helpers.persist_chat import hydrate_context_agents + + ctx = MagicMock() + ctx._raw_agents = None + sentinel = ctx.agent0 + hydrate_context_agents(ctx) + # agent0 should be unchanged — function returned early + assert ctx.agent0 is sentinel + + +# --------------------------------------------------------------------------- +# 4. Chat logs endpoint +# --------------------------------------------------------------------------- + +class TestChatLogsEndpoint: + + def test_chat_logs_import(self): + """ChatLogs endpoint can be imported.""" + from python.api.chat_logs import ChatLogs + assert ChatLogs is not None + + +# --------------------------------------------------------------------------- +# 5. Scoped dirty signals +# --------------------------------------------------------------------------- + +class TestScopedDirtySignals: + + def test_notify_uses_context_scoped_dirty(self, patch_log_deps): + """Log._notify_state_monitor uses mark_dirty_for_context, not mark_dirty_all.""" + from python.helpers.log import Log + + log = Log() + ctx = MagicMock() + ctx.id = "test-ctx-123" + log.context = ctx + + with patch("python.helpers.log._lazy_mark_dirty_for_context") as mock_ctx_dirty, \ + patch("python.helpers.log._lazy_mark_dirty_all") as mock_all_dirty: + log._notify_state_monitor() + mock_ctx_dirty.assert_called_once_with("test-ctx-123", reason="log.Log._notify_state_monitor") + mock_all_dirty.assert_not_called() diff --git a/webui/components/sidebar/chats/chats-store.js b/webui/components/sidebar/chats/chats-store.js index 7d73f6ac54..7c8919b1a2 100644 --- a/webui/components/sidebar/chats/chats-store.js +++ b/webui/components/sidebar/chats/chats-store.js @@ -41,6 +41,7 @@ const model = { // Update contexts from polling applyContexts(contextsList) { + if (contextsList === null || contextsList === undefined) return; // Sort by created_at time (newer first) this.contexts = contextsList.sort( (a, b) => (b.created_at || 0) - (a.created_at || 0) diff --git a/webui/components/sidebar/tasks/tasks-store.js b/webui/components/sidebar/tasks/tasks-store.js index eb44a67de1..1ac2cf4ff6 100644 --- a/webui/components/sidebar/tasks/tasks-store.js +++ b/webui/components/sidebar/tasks/tasks-store.js @@ -13,6 +13,7 @@ const model = { // Apply tasks coming from poll() and keep them sorted (newest first) applyTasks(tasksList) { + if (tasksList === null || tasksList === undefined) return; try { const tasks = Array.isArray(tasksList) ? tasksList : []; const sorted = [...tasks].sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); diff --git a/webui/index.js b/webui/index.js index 4e088eefb0..1e7284b3e9 100644 --- a/webui/index.js +++ b/webui/index.js @@ -402,6 +402,8 @@ function setConnectionStatus(connected) { let lastLogVersion = 0; let lastLogGuid = ""; let lastSpokenNo = 0; +let lastChatListUpdatedAt = 0; +let hasEarlierLogs = false; export function buildStateRequestPayload(options = {}) { const { forceFull = false } = options || {}; @@ -410,6 +412,7 @@ export function buildStateRequestPayload(options = {}) { context: context || null, log_from: forceFull ? 0 : lastLogVersion, notifications_from: forceFull ? 0 : notificationStore.lastNotificationVersion || 0, + chat_list_since: forceFull ? 0 : lastChatListUpdatedAt, timezone, }; } @@ -466,6 +469,13 @@ export async function applySnapshot(snapshot, options = {}) { lastLogVersion = snapshot.log_version; lastLogGuid = snapshot.log_guid; + if (typeof snapshot.chat_list_updated_at === "number") { + lastChatListUpdatedAt = snapshot.chat_list_updated_at; + } + + hasEarlierLogs = !!snapshot.has_earlier_logs; + updateLoadEarlierIndicator(); + updateProgress(snapshot.log_progress, snapshot.log_progress_active); // Update notifications from snapshot @@ -479,13 +489,13 @@ export async function applySnapshot(snapshot, options = {}) { setConnectionStatus(true); } - // Update chats list using store - let contexts = snapshot.contexts || []; - chatsStore.applyContexts(contexts); + if (snapshot.contexts !== null && snapshot.contexts !== undefined) { + chatsStore.applyContexts(snapshot.contexts); + } - // Update tasks list using store - let tasks = snapshot.tasks || []; - tasksStore.applyTasks(tasks); + if (snapshot.tasks !== null && snapshot.tasks !== undefined) { + tasksStore.applyTasks(snapshot.tasks); + } // Make sure the active context is properly selected in both lists if (context) { @@ -640,6 +650,7 @@ export const setContext = function (id) { lastLogGuid = ""; lastLogVersion = 0; lastSpokenNo = 0; + hasEarlierLogs = false; // Stop speech when switching chats speechStore.stopAudio(); @@ -731,6 +742,73 @@ import { store as _chatNavigationStore } from "/components/chat/navigation/chat- // forceScrollChatToBottom is kept here as it is used by system events +export function getHasEarlierLogs() { + return hasEarlierLogs; +} + +export async function loadEarlierLogs() { + if (!hasEarlierLogs || !context) return; + + const chatHistory = document.getElementById("chat-history"); + if (!chatHistory) return; + + const firstMsg = chatHistory.querySelector("[id^='message-']"); + let before = 0; + if (firstMsg) { + const idStr = firstMsg.id.replace("message-", ""); + before = parseInt(idStr, 10); + if (isNaN(before)) before = 0; + } + + try { + const response = await fetch("/chat_logs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ context_id: context, before, limit: 50 }), + }); + const data = await response.json(); + + if (data.logs && data.logs.length > 0) { + const prevScrollHeight = chatHistory.scrollHeight; + const prevScrollTop = chatHistory.scrollTop; + + msgs.prependMessages(data.logs); + + const newScrollHeight = chatHistory.scrollHeight; + chatHistory.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight); + } + + hasEarlierLogs = !!data.has_more; + updateLoadEarlierIndicator(); + } catch (error) { + console.error("Failed to load earlier logs:", error); + } +} + +function updateLoadEarlierIndicator() { + const history = document.getElementById("chat-history"); + if (!history) return; + + let indicator = document.getElementById("load-earlier-indicator"); + + if (hasEarlierLogs) { + if (!indicator) { + indicator = document.createElement("div"); + indicator.id = "load-earlier-indicator"; + indicator.className = "load-earlier-indicator"; + indicator.innerHTML = ''; + indicator.querySelector("button").addEventListener("click", () => { + loadEarlierLogs(); + }); + history.insertBefore(indicator, history.firstChild); + } + } else { + if (indicator) { + indicator.remove(); + } + } +} + // setInterval(poll, 250); async function startPolling() { diff --git a/webui/js/messages.js b/webui/js/messages.js index 7a3de69f4e..d6e872b3c2 100644 --- a/webui/js/messages.js +++ b/webui/js/messages.js @@ -89,14 +89,37 @@ export function setMessages(messages) { }); const results = []; + const BATCH_SIZE = 20; - // process messages - for (let i = 0; i < messages.length; i++) { - _massRender = historyEmpty || (isLargeAppend && i < cutoff); - results.push(setMessage(messages[i]) || {}); + if (massRender && messages.length > BATCH_SIZE) { + for (let i = 0; i < Math.min(BATCH_SIZE, messages.length); i++) { + _massRender = true; + results.push(setMessage(messages[i]) || {}); + } + _massRender = false; + + let batchStart = BATCH_SIZE; + const renderNextBatch = () => { + if (batchStart >= messages.length) return; + const batchEnd = Math.min(batchStart + BATCH_SIZE, messages.length); + _massRender = true; + for (let i = batchStart; i < batchEnd; i++) { + setMessage(messages[i]); + } + _massRender = false; + batchStart = batchEnd; + if (batchStart < messages.length) { + requestAnimationFrame(renderNextBatch); + } + }; + requestAnimationFrame(renderNextBatch); + } else { + for (let i = 0; i < messages.length; i++) { + _massRender = historyEmpty || (isLargeAppend && i < cutoff); + results.push(setMessage(messages[i]) || {}); + } } - // reset _massRender flag _massRender = false; const shouldScroll = historyEmpty || !results[results.length - 1]?.dontScroll; @@ -111,6 +134,27 @@ export function setMessages(messages) { } } +export function prependMessages(messages) { + if (!messages || messages.length === 0) return; + + const history = getChatHistoryEl(); + if (!history) return; + + const existingNodes = Array.from(history.children); + + history.innerHTML = ""; + + _massRender = true; + for (const msg of messages) { + setMessage(msg); + } + _massRender = false; + + for (const node of existingNodes) { + history.appendChild(node); + } +} + // entrypoint called from poll/WS communication, this is how all messages are rendered and updated // input is raw log format export function setMessage({ From fb8ee8c7c44ea416b4ac2d5760a846fcd785d7be Mon Sep 17 00:00:00 2001 From: ivan Date: Thu, 26 Mar 2026 23:09:21 +0200 Subject: [PATCH 04/16] fix: update existing snapshot schema tests for new fields Add chat_list_updated_at and has_earlier_logs to hardcoded snapshot dicts in test_snapshot_schema_v1.py and test_state_snapshot.py. Made-with: Cursor --- tests/helpers/test_snapshot_schema_v1.py | 4 ++++ tests/helpers/test_state_snapshot.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/tests/helpers/test_snapshot_schema_v1.py b/tests/helpers/test_snapshot_schema_v1.py index a472cf4734..77f1b4277d 100644 --- a/tests/helpers/test_snapshot_schema_v1.py +++ b/tests/helpers/test_snapshot_schema_v1.py @@ -17,9 +17,11 @@ "context", "contexts", "tasks", + "chat_list_updated_at", "logs", "log_guid", "log_version", + "has_earlier_logs", "log_progress", "log_progress_active", "paused", @@ -94,9 +96,11 @@ def test_snapshot_schema_rejects_unexpected_top_level_keys(): "context": "", "contexts": [], "tasks": [], + "chat_list_updated_at": 0.0, "logs": [], "log_guid": "", "log_version": 0, + "has_earlier_logs": False, "log_progress": 0, "log_progress_active": False, "paused": False, diff --git a/tests/helpers/test_state_snapshot.py b/tests/helpers/test_state_snapshot.py index d8d4e9ea0c..3298aa3c26 100644 --- a/tests/helpers/test_state_snapshot.py +++ b/tests/helpers/test_state_snapshot.py @@ -23,9 +23,11 @@ def test_validate_accepts_valid_snapshot(self): "context": "", "contexts": [], "tasks": [], + "chat_list_updated_at": 0.0, "logs": [], "log_guid": "", "log_version": 0, + "has_earlier_logs": False, "log_progress": 0, "log_progress_active": False, "paused": False, @@ -56,9 +58,11 @@ def test_validate_rejects_extra_keys(self): "context": "", "contexts": [], "tasks": [], + "chat_list_updated_at": 0.0, "logs": [], "log_guid": "", "log_version": 0, + "has_earlier_logs": False, "log_progress": 0, "log_progress_active": False, "paused": False, @@ -78,9 +82,11 @@ def test_validate_rejects_wrong_type_for_key(self): "context": "", "contexts": "not_a_list", "tasks": [], + "chat_list_updated_at": 0.0, "logs": [], "log_guid": "", "log_version": 0, + "has_earlier_logs": False, "log_progress": 0, "log_progress_active": False, "paused": False, From 5f0499e5be9293ee5cec283ac234ecfe8bac1e3a Mon Sep 17 00:00:00 2001 From: ivan Date: Thu, 26 Mar 2026 23:25:45 +0200 Subject: [PATCH 05/16] fix: address code review feedback on webui performance PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. agent0/streaming_agent → auto-hydrating properties (guards all 7 unguarded access points: api_message, history_get, ctx_window_get, import_knowledge, knowledge_path_get, task_scheduler, settings) 2. Log.output() always returns (list, bool) — no polymorphic return 3. Log.get_items_before() encapsulates pagination logic; chat_logs.py no longer accesses private log._lock 4. Batched rendering defers scrollToBottom to last RAF callback 5. loadEarlierLogs: loading guard + button disabled state 6. prependMessages uses DocumentFragment instead of innerHTML clear 7. Tests: +5 behavioral tests for get_items_before and hydration Made-with: Cursor --- agent.py | 24 ++++- python/api/chat_logs.py | 17 +--- python/helpers/log.py | 30 ++++++- python/helpers/persist_chat.py | 10 +-- python/helpers/state_snapshot.py | 2 +- tests/helpers/test_webui_performance.py | 111 ++++++++++++++++++++++-- webui/index.js | 38 +++++--- webui/js/messages.js | 25 +++--- 8 files changed, 196 insertions(+), 61 deletions(-) diff --git a/agent.py b/agent.py index 5444609ecc..713467e93e 100644 --- a/agent.py +++ b/agent.py @@ -86,7 +86,7 @@ def __init__( self.log = log or Log.Log() self.log.context = self self.paused = paused - self.streaming_agent = streaming_agent + self._streaming_agent = streaming_agent self.task: DeferredTask | None = None self.created_at = created_at or datetime.now(timezone.utc) self.type = type @@ -100,7 +100,25 @@ def __init__( self._raw_streaming_agent_no: int = 0 # initialize agent at last (context is complete now) - self.agent0 = agent0 or Agent(0, self.config, self) + self._agent0 = agent0 or Agent(0, self.config, self) + + @property + def agent0(self): + self._ensure_hydrated() + return self._agent0 + + @agent0.setter + def agent0(self, value): + self._agent0 = value + + @property + def streaming_agent(self): + self._ensure_hydrated() + return self._streaming_agent + + @streaming_agent.setter + def streaming_agent(self, value): + self._streaming_agent = value @staticmethod def get(id: str): @@ -237,7 +255,6 @@ def reset(self): self.paused = False def nudge(self): - self._ensure_hydrated() self.kill_process() self.paused = False self.task = self.communicate(UserMessage(self.agent0.read_prompt("fw.msg_nudge.md"))) @@ -250,7 +267,6 @@ def _ensure_hydrated(self): hydrate_context_agents(self) def get_agent(self): - self._ensure_hydrated() return self.streaming_agent or self.agent0 def is_running(self) -> bool: diff --git a/python/api/chat_logs.py b/python/api/chat_logs.py index 37df56777d..99be6ef0dd 100644 --- a/python/api/chat_logs.py +++ b/python/api/chat_logs.py @@ -10,24 +10,9 @@ async def process(self, input: Input, request: Request) -> Output: before = int(input.get("before", 0)) limit = int(input.get("limit", 50)) - limit = max(1, min(limit, 200)) context = AgentContext.get(context_id) if not context: return {"logs": [], "has_more": False} - log = context.log - with log._lock: - all_logs = list(log.logs) - - if before <= 0: - before = len(all_logs) - - start_idx = max(0, before - limit) - items = all_logs[start_idx:before] - has_more = start_idx > 0 - - return { - "logs": [item.output() for item in items], - "has_more": has_more, - } + return context.log.get_items_before(before, limit) diff --git a/python/helpers/log.py b/python/helpers/log.py index b3893e8f89..bd4d63188c 100644 --- a/python/helpers/log.py +++ b/python/helpers/log.py @@ -405,9 +405,33 @@ def output(self, start=None, end=None, tail=None): out = [logs[u].output() for u in unique] - if tail is not None: - return out, has_earlier - return out + return out, has_earlier + + def get_items_before(self, before: int, limit: int) -> dict: + """Return paginated log items before the given index. + + Args: + before: Return items with index < before. If <= 0, uses total count. + limit: Maximum number of items to return (clamped to 1-200). + + Returns: + {"logs": [item.output(), ...], "has_more": bool} + """ + limit = max(1, min(limit, 200)) + with self._lock: + all_logs = list(self.logs) + + if before <= 0: + before = len(all_logs) + + start_idx = max(0, before - limit) + items = all_logs[start_idx:before] + has_more = start_idx > 0 + + return { + "logs": [item.output() for item in items], + "has_more": has_more, + } def reset(self): with self._lock: diff --git a/python/helpers/persist_chat.py b/python/helpers/persist_chat.py index 73614a3297..f586a7d0d5 100644 --- a/python/helpers/persist_chat.py +++ b/python/helpers/persist_chat.py @@ -116,7 +116,6 @@ def remove_msg_files(ctxid): def _serialize_context(context: AgentContext): - context._ensure_hydrated() # serialize agents agents = [] agent = context.agent0 @@ -222,6 +221,11 @@ def hydrate_context_agents(context: AgentContext): streaming_agent_no = getattr(context, '_raw_streaming_agent_no', 0) + # Clear raw data FIRST to prevent re-entrant hydration when property + # getters trigger _ensure_hydrated() during agent deserialization. + context._raw_agents = None + context._raw_streaming_agent_no = 0 + agent0 = _deserialize_agents(raw_agents, context.config, context) streaming_agent = agent0 while streaming_agent and streaming_agent.number != streaming_agent_no: @@ -230,10 +234,6 @@ def hydrate_context_agents(context: AgentContext): context.agent0 = agent0 context.streaming_agent = streaming_agent - # Clear raw data to free memory - context._raw_agents = None - context._raw_streaming_agent_no = 0 - def _deserialize_agents( agents: list[dict[str, Any]], config: AgentConfig, context: AgentContext diff --git a/python/helpers/state_snapshot.py b/python/helpers/state_snapshot.py index 4a1eb06e61..fe4e4316e7 100644 --- a/python/helpers/state_snapshot.py +++ b/python/helpers/state_snapshot.py @@ -266,7 +266,7 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1: if from_no == 0: logs, has_earlier_logs = active_context.log.output(start=from_no, tail=INITIAL_LOG_TAIL) else: - logs = active_context.log.output(start=from_no) + logs, _ = active_context.log.output(start=from_no) else: logs = [] diff --git a/tests/helpers/test_webui_performance.py b/tests/helpers/test_webui_performance.py index 412ea0c945..de7508aeec 100644 --- a/tests/helpers/test_webui_performance.py +++ b/tests/helpers/test_webui_performance.py @@ -56,12 +56,16 @@ def _make_log_with_items(self, n): log.updates.append(i) return log - def test_output_without_tail_returns_list(self, patch_log_deps): - """Without tail parameter, output() returns a plain list (backward compat).""" + def test_output_always_returns_tuple(self, patch_log_deps): + """output() always returns (list, bool) tuple.""" log = self._make_log_with_items(10) result = log.output() - assert isinstance(result, list) - assert len(result) == 10 + assert isinstance(result, tuple) + assert len(result) == 2 + logs, has_earlier = result + assert isinstance(logs, list) + assert len(logs) == 10 + assert has_earlier is False def test_output_with_tail_returns_tuple(self, patch_log_deps): """With tail parameter, output() returns (list, bool) tuple.""" @@ -154,7 +158,6 @@ def test_raw_agents_default_none(self): """Freshly created AgentContext has _raw_agents = None.""" from agent import AgentContext assert hasattr(AgentContext, "__init__") - # Verify the attribute is documented in the class body import inspect src = inspect.getsource(AgentContext.__init__) assert "_raw_agents" in src @@ -165,14 +168,33 @@ def test_hydrate_noop_when_already_hydrated(self): ctx = MagicMock() ctx._raw_agents = None - sentinel = ctx.agent0 + sentinel = ctx._agent0 hydrate_context_agents(ctx) - # agent0 should be unchanged — function returned early - assert ctx.agent0 is sentinel + assert ctx._agent0 is sentinel + + def test_hydrate_deserializes_agents(self): + """hydrate_context_agents deserializes stored raw agents data.""" + from python.helpers.persist_chat import hydrate_context_agents + + ctx = MagicMock() + ctx._raw_agents = [{"number": 0, "data": {}, "history": ""}] + ctx._raw_streaming_agent_no = 0 + ctx.config = MagicMock() + + with patch("python.helpers.persist_chat._deserialize_agents") as mock_deser: + mock_agent = MagicMock() + mock_agent.number = 0 + mock_agent.data = {} + mock_deser.return_value = mock_agent + + hydrate_context_agents(ctx) + + mock_deser.assert_called_once() + assert ctx._raw_agents is None # --------------------------------------------------------------------------- -# 4. Chat logs endpoint +# 4. Chat logs endpoint — pagination logic # --------------------------------------------------------------------------- class TestChatLogsEndpoint: @@ -182,6 +204,77 @@ def test_chat_logs_import(self): from python.api.chat_logs import ChatLogs assert ChatLogs is not None + def test_get_items_before_basic(self, patch_log_deps): + """get_items_before returns correct slice and has_more flag.""" + from python.helpers.log import Log + + log = Log() + log.context = MagicMock() + for i in range(20): + from python.helpers.log import LogItem + log.logs.append(LogItem( + log=log, no=i, type="info", + heading=f"Item {i}", content=f"Content {i}", + )) + + result = log.get_items_before(before=20, limit=5) + assert len(result["logs"]) == 5 + assert result["has_more"] is True + assert result["logs"][0]["no"] == 15 + assert result["logs"][-1]["no"] == 19 + + def test_get_items_before_from_start(self, patch_log_deps): + """get_items_before with before=5, limit=10 returns first 5 items.""" + from python.helpers.log import Log + + log = Log() + log.context = MagicMock() + for i in range(20): + from python.helpers.log import LogItem + log.logs.append(LogItem( + log=log, no=i, type="info", + heading=f"Item {i}", content=f"Content {i}", + )) + + result = log.get_items_before(before=5, limit=10) + assert len(result["logs"]) == 5 + assert result["has_more"] is False + assert result["logs"][0]["no"] == 0 + + def test_get_items_before_zero_defaults_to_end(self, patch_log_deps): + """get_items_before with before=0 returns items from the end.""" + from python.helpers.log import Log + + log = Log() + log.context = MagicMock() + for i in range(10): + from python.helpers.log import LogItem + log.logs.append(LogItem( + log=log, no=i, type="info", + heading=f"Item {i}", content=f"Content {i}", + )) + + result = log.get_items_before(before=0, limit=3) + assert len(result["logs"]) == 3 + assert result["has_more"] is True + assert result["logs"][-1]["no"] == 9 + + def test_get_items_before_clamps_limit(self, patch_log_deps): + """get_items_before clamps limit to 1-200.""" + from python.helpers.log import Log + + log = Log() + log.context = MagicMock() + for i in range(5): + from python.helpers.log import LogItem + log.logs.append(LogItem( + log=log, no=i, type="info", + heading=f"Item {i}", content=f"Content {i}", + )) + + result = log.get_items_before(before=5, limit=999) + assert len(result["logs"]) == 5 + # --------------------------------------------------------------------------- # 5. Scoped dirty signals diff --git a/webui/index.js b/webui/index.js index 1e7284b3e9..373157cdb7 100644 --- a/webui/index.js +++ b/webui/index.js @@ -404,6 +404,7 @@ let lastLogGuid = ""; let lastSpokenNo = 0; let lastChatListUpdatedAt = 0; let hasEarlierLogs = false; +let loadingEarlierLogs = false; export function buildStateRequestPayload(options = {}) { const { forceFull = false } = options || {}; @@ -747,20 +748,21 @@ export function getHasEarlierLogs() { } export async function loadEarlierLogs() { - if (!hasEarlierLogs || !context) return; - - const chatHistory = document.getElementById("chat-history"); - if (!chatHistory) return; - - const firstMsg = chatHistory.querySelector("[id^='message-']"); - let before = 0; - if (firstMsg) { - const idStr = firstMsg.id.replace("message-", ""); - before = parseInt(idStr, 10); - if (isNaN(before)) before = 0; - } + if (loadingEarlierLogs || !hasEarlierLogs || !context) return; + loadingEarlierLogs = true; try { + const chatHistory = document.getElementById("chat-history"); + if (!chatHistory) return; + + const firstMsg = chatHistory.querySelector("[id^='message-']"); + let before = 0; + if (firstMsg) { + const idStr = firstMsg.id.replace("message-", ""); + before = parseInt(idStr, 10); + if (isNaN(before)) before = 0; + } + const response = await fetch("/chat_logs", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -782,6 +784,8 @@ export async function loadEarlierLogs() { updateLoadEarlierIndicator(); } catch (error) { console.error("Failed to load earlier logs:", error); + } finally { + loadingEarlierLogs = false; } } @@ -798,7 +802,15 @@ function updateLoadEarlierIndicator() { indicator.className = "load-earlier-indicator"; indicator.innerHTML = ''; indicator.querySelector("button").addEventListener("click", () => { - loadEarlierLogs(); + const btn = indicator.querySelector("button"); + btn.textContent = "Loading..."; + btn.disabled = true; + loadEarlierLogs().finally(() => { + if (btn) { + btn.textContent = "Load earlier messages"; + btn.disabled = false; + } + }); }); history.insertBefore(indicator, history.firstChild); } diff --git a/webui/js/messages.js b/webui/js/messages.js index d6e872b3c2..b0090e565c 100644 --- a/webui/js/messages.js +++ b/webui/js/messages.js @@ -98,9 +98,16 @@ export function setMessages(messages) { } _massRender = false; + const shouldScroll = historyEmpty || !results[results.length - 1]?.dontScroll; + let batchStart = BATCH_SIZE; const renderNextBatch = () => { - if (batchStart >= messages.length) return; + if (batchStart >= messages.length) { + if (shouldScroll) { + mainScroller.reApplyScroll(); + } + return; + } const batchEnd = Math.min(batchStart + BATCH_SIZE, messages.length); _massRender = true; for (let i = batchStart; i < batchEnd; i++) { @@ -108,11 +115,10 @@ export function setMessages(messages) { } _massRender = false; batchStart = batchEnd; - if (batchStart < messages.length) { - requestAnimationFrame(renderNextBatch); - } + requestAnimationFrame(renderNextBatch); }; requestAnimationFrame(renderNextBatch); + return results; } else { for (let i = 0; i < messages.length; i++) { _massRender = historyEmpty || (isLargeAppend && i < cutoff); @@ -140,9 +146,10 @@ export function prependMessages(messages) { const history = getChatHistoryEl(); if (!history) return; - const existingNodes = Array.from(history.children); - - history.innerHTML = ""; + const existingFragment = document.createDocumentFragment(); + while (history.firstChild) { + existingFragment.appendChild(history.firstChild); + } _massRender = true; for (const msg of messages) { @@ -150,9 +157,7 @@ export function prependMessages(messages) { } _massRender = false; - for (const node of existingNodes) { - history.appendChild(node); - } + history.appendChild(existingFragment); } // entrypoint called from poll/WS communication, this is how all messages are rendered and updated From ff787164265a75bb6028697076e37888f17bb584 Mon Sep 17 00:00:00 2001 From: ivan Date: Fri, 27 Mar 2026 08:59:01 +0200 Subject: [PATCH 06/16] fix: update test_log.py for Log.output() tuple return type Three tests expected log.output() to return a plain list; now it always returns (list, bool). Updated to unpack correctly. Made-with: Cursor --- tests/helpers/test_log.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/helpers/test_log.py b/tests/helpers/test_log.py index 7753aaa141..26a30358af 100644 --- a/tests/helpers/test_log.py +++ b/tests/helpers/test_log.py @@ -236,10 +236,11 @@ def test_log_output_returns_updates(self, patch_log_dependencies): log = Log() log.log("info", heading="A") log.log("info", heading="B") - out = log.output() + out, has_earlier = log.output() assert len(out) == 2 assert out[0]["heading"] == "A" assert out[1]["heading"] == "B" + assert has_earlier is False def test_log_output_with_start_end(self, patch_log_dependencies): from python.helpers.log import Log @@ -248,7 +249,7 @@ def test_log_output_with_start_end(self, patch_log_dependencies): log.log("info", heading="A") log.log("info", heading="B") log.log("info", heading="C") - out = log.output(start=1, end=2) + out, _ = log.output(start=1, end=2) assert len(out) == 1 assert out[0]["heading"] == "B" @@ -314,7 +315,8 @@ def test_log_with_id(self, patch_log_dependencies): log = Log() item = log.log("info", heading="H", id="custom-id") assert item.id == "custom-id" - assert log.output()[0]["id"] == "custom-id" + out, _ = log.output() + assert out[0]["id"] == "custom-id" def test_log_update_progress_persistent_vs_temporary(self, patch_log_dependencies): from python.helpers.log import Log From 63eea9a02b32fde36bb89d5fa6dfcb7586030c8c Mon Sep 17 00:00:00 2001 From: ivan Date: Fri, 27 Mar 2026 09:19:11 +0200 Subject: [PATCH 07/16] fix: trigger chat list refresh on chat rename _60_rename_chat.py sets context.name but never notified clients. Before scoped dirty signals this was masked by mark_dirty_all on every log update. Now we explicitly touch_chat_list() + mark_dirty_all() after rename so all tabs see the updated name immediately. Made-with: Cursor --- python/extensions/monologue_start/_60_rename_chat.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/extensions/monologue_start/_60_rename_chat.py b/python/extensions/monologue_start/_60_rename_chat.py index dba8de51cd..c953473cbe 100644 --- a/python/extensions/monologue_start/_60_rename_chat.py +++ b/python/extensions/monologue_start/_60_rename_chat.py @@ -35,5 +35,9 @@ async def change_name(self): # apply to context and save self.agent.context.name = new_name persist_chat.save_tmp_chat(self.agent.context) + from python.helpers.state_snapshot import touch_chat_list + from python.helpers.state_monitor_integration import mark_dirty_all + touch_chat_list() + mark_dirty_all(reason="rename_chat") except Exception as e: pass # non-critical From d66ac15f74137da86553ced756f3f7219c906faf Mon Sep 17 00:00:00 2001 From: ivan Date: Fri, 27 Mar 2026 09:33:28 +0200 Subject: [PATCH 08/16] docs: sidebar chat list UX improvements design spec Covers three changes: move unread indicator to separate dot, add relative timestamps, sort chats by last_message. Made-with: Cursor --- .../2026-03-27-sidebar-chat-list-ux-design.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-27-sidebar-chat-list-ux-design.md diff --git a/docs/superpowers/specs/2026-03-27-sidebar-chat-list-ux-design.md b/docs/superpowers/specs/2026-03-27-sidebar-chat-list-ux-design.md new file mode 100644 index 0000000000..1d211cbb71 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-sidebar-chat-list-ux-design.md @@ -0,0 +1,97 @@ +# Sidebar Chat List UX Improvements + +## Problem + +Three usability issues in the sidebar chat list: + +1. **Unread indicator overlaps project color** — the blue "unread" dot overrides the project color ball, losing project identity. +2. **No timestamp** — no way to see when a chat was last active without opening it. +3. **Static sort order** — chats are sorted by creation time. Active chats stay buried, making it hard to find recently active conversations. + +## Design + +### Chat item layout + +``` +[project-ball] [chat-name] [unread-dot?] [timestamp] [close-btn] +``` + +- `project-ball` — always shows project color (never overridden by unread state) +- `chat-name` — truncated with ellipsis, `flex: 1` +- `unread-dot` — 6px blue circle, shown only when `context.unread === true` and chat is not selected +- `timestamp` — relative time since `last_message`, right-aligned +- `close-btn` — existing delete button, visible on hover + +### 1. Unread indicator + +**Current**: `.chat-unread .project-color-ball` CSS rule overrides background to blue. + +**Change**: Remove the `.chat-unread .project-color-ball` CSS rules. Add a separate `` element after the chat name, controlled by `x-show="context.unread && context.id !== $store.chats.selected"`. + +Styling: +- `width: 6px; height: 6px; border-radius: 50%` +- `background-color: #4fc3f7` (dark mode), `#0288d1` (light mode) +- `flex-shrink: 0` + +Keep `.chat-unread .chat-name { font-weight: 600 }` — bold name is a good secondary signal. + +### 2. Relative timestamp + +Add `` after the unread dot. Shows time since `last_message` in compact format. + +Format rules: +| Elapsed | Display | +|---|---| +| < 60s | `Ns` (e.g. `5s`) | +| < 60m | `Nm` (e.g. `10m`) | +| < 24h | `Nh` (e.g. `5h`) | +| < 7d | `Nd` (e.g. `3d`) | +| >= 7d | `Nw` (e.g. `2w`) | + +`title` attribute shows full localized datetime on hover (using `Date.toLocaleString()`). + +The helper function `formatRelativeTime(isoString)` lives in `chats-store.js` (or inline in the template via Alpine `x-text`). It computes relative time from `context.last_message`. + +Timestamps update on every `applyContexts` call (snapshot push, every ~2-5s during active sessions). No separate `setInterval` timer needed — staleness of a few seconds is acceptable. + +Styling: +- `font-size: var(--font-size-xs, 0.7em)` +- `color: var(--color-secondary); opacity: 0.6` +- `flex-shrink: 0; margin-left: auto; padding: 0 4px` +- `white-space: nowrap` + +### 3. Sort by last update + +**Current** (line 47 of `chats-store.js`): +```javascript +this.contexts = contextsList.sort( + (a, b) => (b.created_at || 0) - (a.created_at || 0) +); +``` + +**Change**: Sort by `last_message` descending. The `last_message` field is an ISO datetime string from `AgentContext.output()`. Convert to comparable values: +```javascript +this.contexts = contextsList.sort((a, b) => { + const ta = a.last_message ? new Date(a.last_message).getTime() : 0; + const tb = b.last_message ? new Date(b.last_message).getTime() : 0; + return tb - ta; +}); +``` + +Chats with more recent activity float to the top. + +## Files changed + +| File | Change | +|---|---| +| `webui/components/sidebar/chats/chats-list.html` | Add unread-dot span, timestamp span; remove `.chat-unread .project-color-ball` CSS; add new CSS for `.unread-dot` and `.chat-timestamp` | +| `webui/components/sidebar/chats/chats-store.js` | Change sort key from `created_at` to `last_message`; add `formatRelativeTime()` helper | + +## Testing + +Manual verification: +1. Create a new chat, send a message — verify timestamp shows `1s`, `2s`, etc. +2. Switch away and back — verify unread dot appears to the right of the name, project color ball keeps its color. +3. Send messages in different chats — verify most recently active chat moves to the top. +4. Hover over timestamp — verify full datetime tooltip. +5. Light mode — verify unread dot uses `#0288d1`. From 83c67adf3640d4665010b2090d038d21bb91c0bb Mon Sep 17 00:00:00 2001 From: ivan Date: Fri, 27 Mar 2026 09:37:54 +0200 Subject: [PATCH 09/16] feat: sidebar chat list UX improvements - Move unread indicator to separate blue dot right of chat name (no longer overrides project color ball) - Add relative timestamp (1s/5m/2h/3d/1w) with full datetime tooltip - Sort chats by last_message descending (most recent activity first) Made-with: Cursor --- .../components/sidebar/chats/chats-list.html | 33 ++++++++++++++++--- webui/components/sidebar/chats/chats-store.js | 28 +++++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/webui/components/sidebar/chats/chats-list.html b/webui/components/sidebar/chats/chats-list.html index a5203cfe9f..751c556796 100644 --- a/webui/components/sidebar/chats/chats-list.html +++ b/webui/components/sidebar/chats/chats-list.html @@ -37,6 +37,11 @@

Chats

:style="context.project?.color ? { backgroundColor: context.project.color } : { border: '1px solid var(--color-border)' }">
+ +