diff --git a/agent.py b/agent.py index 0460aabf71..d31687c0e0 100644 --- a/agent.py +++ b/agent.py @@ -1,4 +1,4 @@ -import asyncio, random, string, threading +import asyncio, random, string, threading, time import nest_asyncio nest_asyncio.apply() @@ -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 @@ -94,8 +94,31 @@ 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) + 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,6 +260,12 @@ def nudge(self): 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): return self.streaming_agent or self.agent0 @@ -293,6 +322,12 @@ async def _process_chain(self, agent: "Agent", msg: "UserMessage|str", user=True return response except Exception as e: agent.handle_critical_exception(e) + finally: + if user: + 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="process_chain_end") @dataclass @@ -663,10 +698,20 @@ def get_data(self, field: str): def set_data(self, field: str, value): self.data[field] = value + _last_msg_touch: float = 0.0 + _MSG_TOUCH_INTERVAL: float = 5.0 + def hist_add_message( self, ai: bool, content: history.MessageContent, tokens: int = 0 ): - self.last_message = datetime.now(timezone.utc) + now = datetime.now(timezone.utc) + self.last_message = now + self.context.last_message = now + now_mono = time.time() + if now_mono - Agent._last_msg_touch >= Agent._MSG_TOUCH_INTERVAL: + Agent._last_msg_touch = now_mono + from python.helpers.state_snapshot import touch_chat_list + touch_chat_list() # Allow extensions to process content before adding to history content_data = {"content": content} asyncio.run( 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..1ff84eb5b8 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-webui-performance-optimization-design.md @@ -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). 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`. 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..99be6ef0dd --- /dev/null +++ b/python/api/chat_logs.py @@ -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) 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/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 diff --git a/python/helpers/log.py b/python/helpers/log.py index 2cac02ed97..bd4d63188c 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,13 +391,47 @@ 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) - return out + + 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] + + 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 1104744483..f586a7d0d5 100644 --- a/python/helpers/persist_chat.py +++ b/python/helpers/persist_chat.py @@ -182,13 +182,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,21 +204,36 @@ 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) + + # 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 != 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 - 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 f845aa6d3d..fe4e4316e7 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_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 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, diff --git a/tests/helpers/test_webui_performance.py b/tests/helpers/test_webui_performance.py new file mode 100644 index 0000000000..2949933f3e --- /dev/null +++ b/tests/helpers/test_webui_performance.py @@ -0,0 +1,524 @@ +"""Tests for webui performance optimizations: tail-based log output, conditional +chat list, lazy deserialization, chat-logs endpoint, scoped dirty signals, +chat rename refresh, last_message updates, and process_chain completion signals.""" + +import sys +import time +from datetime import datetime, timezone +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_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, 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.""" + 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__") + 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) + 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 — pagination logic +# --------------------------------------------------------------------------- + +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 + + 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 +# --------------------------------------------------------------------------- + +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() + + +# --------------------------------------------------------------------------- +# 6. Chat rename triggers chat list refresh +# --------------------------------------------------------------------------- + +class TestChatRenameRefresh: + + def test_rename_calls_touch_and_dirty(self): + """RenameChat.change_name calls touch_chat_list + mark_dirty_all after rename.""" + from python.extensions.monologue_start._60_rename_chat import RenameChat + import asyncio + + async def fake_call_utility_model(**kwargs): + return "New Name" + + ext = MagicMock(spec=RenameChat) + ext.agent = MagicMock() + ext.agent.context = MagicMock() + ext.agent.context.name = "Old Name" + ext.agent.history.output_text.return_value = "hi" + ext.agent.config.utility_model.ctx_length = 1000 + ext.agent.read_prompt = MagicMock(return_value="prompt") + ext.agent.call_utility_model = fake_call_utility_model + + with patch("python.extensions.monologue_start._60_rename_chat.persist_chat") as mock_persist, \ + patch("python.extensions.monologue_start._60_rename_chat.tokens") as mock_tokens, \ + patch("python.helpers.state_snapshot.touch_chat_list") as mock_touch, \ + patch("python.helpers.state_monitor_integration.mark_dirty_all") as mock_dirty: + mock_tokens.trim_to_tokens.return_value = "hi" + asyncio.get_event_loop().run_until_complete( + RenameChat.change_name(ext) + ) + mock_touch.assert_called_once() + mock_dirty.assert_called_once_with(reason="rename_chat") + assert ext.agent.context.name == "New Name" + + +# --------------------------------------------------------------------------- +# 7. hist_add_message updates AgentContext.last_message +# --------------------------------------------------------------------------- + +class TestHistAddMessageUpdatesContext: + + def setup_method(self): + from agent import Agent + Agent._last_msg_touch = 0.0 + + def test_hist_add_message_sets_both_timestamps(self): + """hist_add_message updates both Agent.last_message and context.last_message.""" + from agent import Agent + + agent = MagicMock(spec=Agent) + agent.context = MagicMock() + agent.context.last_message = datetime(2020, 1, 1, tzinfo=timezone.utc) + agent.history = MagicMock() + agent.history.add_message = MagicMock(return_value=MagicMock()) + + with patch("agent.asyncio") as mock_asyncio, \ + patch("python.helpers.state_snapshot.touch_chat_list"): + mock_asyncio.run = MagicMock() + Agent.hist_add_message(agent, ai=False, content="test") + + assert agent.context.last_message > datetime(2020, 1, 1, tzinfo=timezone.utc) + + def test_hist_add_message_debounces_touch_chat_list(self): + """touch_chat_list is debounced: first call fires, rapid follow-up is skipped.""" + from agent import Agent + + agent = MagicMock(spec=Agent) + agent.context = MagicMock() + agent.context.last_message = datetime(2020, 1, 1, tzinfo=timezone.utc) + agent.history = MagicMock() + agent.history.add_message = MagicMock(return_value=MagicMock()) + + with patch("agent.asyncio") as mock_asyncio, \ + patch("python.helpers.state_snapshot.touch_chat_list") as mock_touch: + mock_asyncio.run = MagicMock() + Agent._last_msg_touch = 0.0 + Agent.hist_add_message(agent, ai=False, content="msg1") + Agent.hist_add_message(agent, ai=True, content="msg2") + Agent.hist_add_message(agent, ai=False, content="msg3") + assert mock_touch.call_count == 1 + + def test_hist_add_message_fires_after_interval(self): + """touch_chat_list fires again after the debounce interval elapses.""" + from agent import Agent + + agent = MagicMock(spec=Agent) + agent.context = MagicMock() + agent.context.last_message = datetime(2020, 1, 1, tzinfo=timezone.utc) + agent.history = MagicMock() + agent.history.add_message = MagicMock(return_value=MagicMock()) + + with patch("agent.asyncio") as mock_asyncio, \ + patch("python.helpers.state_snapshot.touch_chat_list") as mock_touch: + mock_asyncio.run = MagicMock() + Agent._last_msg_touch = 0.0 + Agent.hist_add_message(agent, ai=False, content="msg1") + assert mock_touch.call_count == 1 + Agent._last_msg_touch = time.time() - Agent._MSG_TOUCH_INTERVAL - 1 + Agent.hist_add_message(agent, ai=True, content="msg2") + assert mock_touch.call_count == 2 + + +# --------------------------------------------------------------------------- +# 8. _process_chain finally block broadcasts completion +# --------------------------------------------------------------------------- + +class TestProcessChainCompletion: + + def test_process_chain_broadcasts_on_completion(self): + """_process_chain calls touch_chat_list + mark_dirty_all in finally for user messages.""" + import asyncio + from agent import AgentContext + + async def fake_monologue(): + return "response" + + async def fake_call_extensions(*args, **kwargs): + return None + + ctx = MagicMock(spec=AgentContext) + mock_agent = MagicMock() + mock_agent.monologue = fake_monologue + mock_agent.data = {} + mock_agent.hist_add_user_message = MagicMock() + ctx.get_agent.return_value = mock_agent + mock_agent.call_extensions = fake_call_extensions + + with patch("python.helpers.state_snapshot.touch_chat_list") as mock_touch, \ + patch("python.helpers.state_monitor_integration.mark_dirty_all") as mock_dirty: + asyncio.get_event_loop().run_until_complete( + AgentContext._process_chain(ctx, mock_agent, "hello", user=True) + ) + mock_touch.assert_called() + mock_dirty.assert_called_with(reason="process_chain_end") + + def test_process_chain_skips_broadcast_for_subordinate(self): + """_process_chain does NOT broadcast for subordinate calls (user=False).""" + import asyncio + from agent import AgentContext + + async def fake_monologue(): + return "response" + + async def fake_call_extensions(*args, **kwargs): + return None + + ctx = MagicMock(spec=AgentContext) + mock_agent = MagicMock() + mock_agent.monologue = fake_monologue + mock_agent.data = {} + mock_agent.hist_add_tool_result = MagicMock() + ctx.get_agent.return_value = mock_agent + mock_agent.call_extensions = fake_call_extensions + + with patch("python.helpers.state_snapshot.touch_chat_list") as mock_touch, \ + patch("python.helpers.state_monitor_integration.mark_dirty_all") as mock_dirty: + asyncio.get_event_loop().run_until_complete( + AgentContext._process_chain(ctx, mock_agent, "result", user=False) + ) + mock_touch.assert_not_called() + mock_dirty.assert_not_called() + + +# --------------------------------------------------------------------------- +# 9. Frontend formatRelativeTime (unit logic test) +# --------------------------------------------------------------------------- + +class TestFormatRelativeTime: + """Test the Python-side equivalent of the JS formatRelativeTime boundaries.""" + + @staticmethod + def _format(seconds): + """Mirror the JS formatRelativeTime logic for unit testing boundaries.""" + if seconds < 60: + return f"{seconds}s" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m" + hours = minutes // 60 + if hours < 24: + return f"{hours}h" + days = hours // 24 + if days < 7: + return f"{days}d" + weeks = days // 7 + return f"{weeks}w" + + def test_seconds(self): + assert self._format(0) == "0s" + assert self._format(59) == "59s" + + def test_minutes(self): + assert self._format(60) == "1m" + assert self._format(3599) == "59m" + + def test_hours(self): + assert self._format(3600) == "1h" + assert self._format(86399) == "23h" + + def test_days(self): + assert self._format(86400) == "1d" + assert self._format(604799) == "6d" + + def test_weeks(self): + assert self._format(604800) == "1w" + assert self._format(1209600) == "2w" + + +# --------------------------------------------------------------------------- +# 10. Sort order uses last_message, not created_at +# --------------------------------------------------------------------------- + +class TestChatListSortOrder: + + def test_chats_store_sorts_by_last_message(self): + """chats-store.js sorts contexts by last_message, not created_at.""" + store_path = PROJECT_ROOT / "webui" / "components" / "sidebar" / "chats" / "chats-store.js" + content = store_path.read_text() + assert "last_message" in content + assert "new Date(a.last_message)" in content or "a.last_message" in content + assert "created_at" not in content.split("sort(")[1].split(");")[0] if "sort(" in content else True diff --git a/webui/components/sidebar/chats/chats-list.html b/webui/components/sidebar/chats/chats-list.html index a5203cfe9f..731c52116d 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)' }">
+ + '; + indicator.querySelector("button").addEventListener("click", () => { + 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); + } + } 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..b0090e565c 100644 --- a/webui/js/messages.js +++ b/webui/js/messages.js @@ -89,14 +89,43 @@ 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; + + const shouldScroll = historyEmpty || !results[results.length - 1]?.dontScroll; + + let batchStart = BATCH_SIZE; + const renderNextBatch = () => { + 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++) { + setMessage(messages[i]); + } + _massRender = false; + batchStart = batchEnd; + requestAnimationFrame(renderNextBatch); + }; + requestAnimationFrame(renderNextBatch); + return results; + } 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 +140,26 @@ export function setMessages(messages) { } } +export function prependMessages(messages) { + if (!messages || messages.length === 0) return; + + const history = getChatHistoryEl(); + if (!history) return; + + const existingFragment = document.createDocumentFragment(); + while (history.firstChild) { + existingFragment.appendChild(history.firstChild); + } + + _massRender = true; + for (const msg of messages) { + setMessage(msg); + } + _massRender = false; + + history.appendChild(existingFragment); +} + // entrypoint called from poll/WS communication, this is how all messages are rendered and updated // input is raw log format export function setMessage({