This repository was archived by the owner on Apr 4, 2026. It is now read-only.
forked from agent0ai/agent-zero
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: optimize web UI performance for large chat histories #21
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
21de061
Add design spec for web UI performance optimization
Nafania 55bfcad
Fix spec review issues: clarify initial chat_list_since, tail
Nafania 65d7547
feat: optimize web UI performance for large chat histories
Nafania fb8ee8c
fix: update existing snapshot schema tests for new fields
Nafania 5f0499e
fix: address code review feedback on webui performance PR
Nafania ff78716
fix: update test_log.py for Log.output() tuple return type
Nafania cb39009
Merge remote-tracking branch 'origin/main' into feat/webui-performanc…
Nafania 63eea9a
fix: trigger chat list refresh on chat rename
Nafania d66ac15
docs: sidebar chat list UX improvements design spec
Nafania 83c67ad
feat: sidebar chat list UX improvements
Nafania 0eecdfc
fix: make sidebar timestamps update live every 10s
Nafania 43bbccd
fix: update AgentContext.last_message on hist_add_message
Nafania 6e5684c
fix: touch_chat_list when agent finishes processing
Nafania 26b37ed
fix: pass _tick as arg instead of void+comma in x-text
Nafania 76a7fda
fix: broadcast to all clients when agent finishes processing
Nafania d680d5a
test: add tests for chat rename refresh, last_message updates,
Nafania 774cbc5
fix: debounce touch_chat_list in hist_add_message, rewrite tests as b…
Nafania File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
159 changes: 159 additions & 0 deletions
159
docs/superpowers/specs/2026-03-26-webui-performance-optimization-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| # 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. 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 | ||
|
|
||
| **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. 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`. | ||
|
|
||
| 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; `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 | ||
|
|
||
| - **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). |
97 changes: 97 additions & 0 deletions
97
docs/superpowers/specs/2026-03-27-sidebar-chat-list-ux-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<span class="unread-dot">` 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 `<span class="chat-timestamp">` 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`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| 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)) | ||
|
|
||
| context = AgentContext.get(context_id) | ||
| if not context: | ||
| return {"logs": [], "has_more": False} | ||
|
|
||
| return context.log.get_items_before(before, limit) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Important: Missing
_ensure_hydrated()guards on external call sites.This method is only called from
nudge(),get_agent(), and_serialize_context(). But several other code paths accesscontext.agent0orcontext.streaming_agentdirectly:python/api/api_message.py:84—context.agent0.config.profilepython/api/history_get.py:8—context.streaming_agent or context.agent0python/api/ctx_window_get.py:10—context.streaming_agent or context.agent0python/api/import_knowledge.py:19,42—context.agent0python/api/knowledge_path_get.py:18—context.agent0python/helpers/task_scheduler.py:894—context.streaming_agent or context.agent0python/helpers/settings.py:623—ctx.agent0Before hydration, these will get the default empty
Agent(0, config, self)created in__init__, not the deserialized agent with history. For example, exporting chat history (history_get) on a lazily-loaded chat would return empty.Suggested fix: either (a) add
_ensure_hydrated()calls to all external access points that touchagent0(safest), or (b) override__getattr__to auto-hydrate onagent0access (more elegant but harder to debug).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
agent0andstreaming_agentare now auto-hydrating properties — getter calls_ensure_hydrated()before returning the backing field. All 7 unguarded access points are now covered automatically. No need to hunt for callers.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verified. Property-based auto-hydration is the right approach — covers all callers without hunting for them.
Re-entrancy protection is correct:
hydrate_context_agentsclears_raw_agents = Nonebefore calling_deserialize_agents, so if anything during deserialization accessescontext.agent0, the property getter sees_raw_agents is Noneand returns the backing field immediately (no infinite recursion).One edge case to be aware of: if
_deserialize_agentsraises,_raw_agentsis already cleared, so the context permanently has the default empty agent with no way to retry hydration. Acceptable — if deserialization fails, the data is corrupt anyway.