Chat rendering/pagination, Add chat-naming and pin-to-top plugins

Refactor of messages.js to support lazy rendering. Move chat rename functionality out of core and into a new _chat_naming plugin, and add a built-in _pin_to_top plugin for sidebar pinning. The chat-naming plugin includes API handlers, prompts, helper logic for selecting user messages and budgeted Utility Model calls, a python monologue_start extension, web UI (modal, store, config, sidebar action), and comprehensive tests. Removed the old core renaming extension and deprecated prompts/tests. Also added pin-to-top plugin files (API, helpers, webui store, tests/docs) and updated various AGENTS.md docs to reflect the new plugins and lifecycle clarifications. Minor UI/js tweak: reorder speak/copy buttons in browser tool handler and add a new webui message-window.js along with related webui/store/component updates and test adjustments.

frdel committed Jul 29, 2026 at 08:17 UTC b6be345a56971862bc6e8776a65d92a663808374
62 files changed +4090 -432
extensions/python/AGENTS.md
+1 -1
@@ -48,7 +48,7 @@ Direct child DOX files:
48 | [message_loop_prompts_before/AGENTS.md](message_loop_prompts_before/AGENTS.md) | Pre-prompt-construction message-loop gates. |
49 | [message_loop_start/AGENTS.md](message_loop_start/AGENTS.md) | Start-of-message-loop iteration state. |
50 | [monologue_end/AGENTS.md](monologue_end/AGENTS.md) | End-of-monologue UI and cleanup behavior. |
51 -| [monologue_start/AGENTS.md](monologue_start/AGENTS.md) | Start-of-monologue behavior such as chat renaming. |
51 +| [monologue_start/AGENTS.md](monologue_start/AGENTS.md) | Core start-of-monologue lifecycle extensions. |
52 | [process_chain_end/AGENTS.md](process_chain_end/AGENTS.md) | Process-chain completion and queued-message handling. |
53 | [reasoning_stream/AGENTS.md](reasoning_stream/AGENTS.md) | Full reasoning stream handling. |
54 | [reasoning_stream_chunk/AGENTS.md](reasoning_stream_chunk/AGENTS.md) | Reasoning stream chunk masking. |
extensions/python/monologue_start/AGENTS.md
+6 -7
@@ -2,25 +2,24 @@
2
3 ## Purpose
4
5 -- Own backend behavior that runs when a monologue starts.
5 +- Own core backend behavior that runs when a monologue starts.
6
7 ## Ownership
8
9 -- Ordered Python files own automatic chat renaming and future monologue-start setup.
9 +- Ordered Python files own core monologue-start setup.
10
11 ## Local Contracts
12
13 -- Keep automatic rename behavior bounded and non-destructive.
14 -- Do not override explicit user chat names without the intended guard conditions.
15 -- Surface Utility Model rename failures with one scoped error notification per chat.
13 +- Keep start-of-monologue work bounded and non-blocking where appropriate.
14 +- Plugin-specific behavior belongs in the owning plugin's `extensions/python/monologue_start/` directory.
15
16 ## Work Guidance
17
19 -- Coordinate rename behavior with chat persistence and WebUI refresh after successful saves.
18 +- Coordinate lifecycle changes with the message loop and relevant plugin hooks.
19
20 ## Verification
21
23 -- Smoke-test new chat naming and existing named chat behavior after changes.
22 +- Smoke-test the first monologue after a new user message.
23
24 ## Child DOX Index
25
extensions/python/monologue_start/_60_rename_chat.py deleted
-65
@@ -1,65 +0,0 @@
1 -from helpers import persist_chat, tokens
2 -from helpers.extension import Extension
3 -from helpers.notification import NotificationManager, NotificationPriority, NotificationType
4 -from helpers.state_monitor_integration import mark_dirty_all
5 -from agent import LoopData
6 -import asyncio
7 -
8 -
9 -class RenameChat(Extension):
10 -
11 - async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
12 - asyncio.create_task(self.change_name())
13 -
14 - async def change_name(self):
15 - if not self.agent:
16 - return
17 -
18 - try:
19 - # prepare history
20 - from plugins._model_config.helpers.model_config import get_utility_model_config
21 - util_cfg = get_utility_model_config(self.agent)
22 - history_text = self.agent.history.output_text()
23 - ctx_length = min(
24 - int(util_cfg.get("ctx_length", 128000) * 0.7), 5000
25 - )
26 - history_text = tokens.trim_to_tokens(history_text, ctx_length, "start")
27 - # prepare system and user prompt
28 - system = self.agent.read_prompt("fw.rename_chat.sys.md")
29 - current_name = self.agent.context.name
30 - message = self.agent.read_prompt(
31 - "fw.rename_chat.msg.md", current_name=current_name, history=history_text
32 - )
33 - # call utility model
34 - try:
35 - new_name = await self.agent.call_utility_model(
36 - system=system, message=message, background=True
37 - )
38 - except Exception:
39 - NotificationManager.send_notification(
40 - type=NotificationType.ERROR,
41 - priority=NotificationPriority.NORMAL,
42 - title="Chat Rename Failed",
43 - message="Automatic chat renaming failed because the Utility Model was not reachable.",
44 - detail=(
45 - "Automatic chat renaming uses the Utility Model. Check Settings > Models > "
46 - "Utility Model, provider/API key, and network reachability."
47 - ),
48 - display_time=10,
49 - group="chat_rename",
50 - id=f"chat_rename_failed_{self.agent.context.id}",
51 - )
52 - return
53 - # update name
54 - if new_name:
55 - new_name = " ".join(str(new_name).split())
56 - if len(new_name) > 40:
57 - new_name = new_name[:40] + "..."
58 - if not new_name:
59 - return
60 - # apply to context and save
61 - self.agent.context.name = new_name
62 - persist_chat.save_tmp_chat(self.agent.context)
63 - mark_dirty_all(reason="monologue_start.RenameChat.change_name")
64 - except Exception:
65 - pass # non-critical
extensions/webui/set_messages_after_loop/AGENTS.md
+1
@@ -12,6 +12,7 @@
12
13 - JavaScript modules must export a default function when present.
14 - Preserve message DOM stability and avoid duplicate controls on repeated renders.
15 +- Offscreen live entries in a virtualized chat may appear in `context.results` with `result.virtualized === true` and `result.element === null`; DOM extensions must guard `element`, while args-only side effects may still run.
16
17 ## Work Guidance
18
plugins/AGENTS.md
+3
@@ -17,6 +17,7 @@
17 ## Local Contracts
18
19 - Every plugin directory must include a valid `plugin.yaml`.
20 +- Bundled plugin directory names and manifest `name` values must start with `_` to avoid collisions with community plugins.
21 - Runtime manifest fields include `name`, `title`, `description`, `version`, `settings_sections`, `per_project_config`, `per_agent_config`, and `always_enabled`.
22 - Core plugins may use `plugins.<plugin_name>...` imports when they are shipped from this tree.
23 - User plugins under `usr/plugins/` must use `usr.plugins.<plugin_name>...` imports and avoid `sys.path` hacks or persistent symlink-based imports.
@@ -70,6 +71,7 @@ Direct child DOX files:
71 | [_browser/AGENTS.md](_browser/AGENTS.md) | Playwright browser tool, helpers, viewer, and browser panel UI. |
72 | [_chat_branching/AGENTS.md](_chat_branching/AGENTS.md) | Chat branching from an existing message. |
73 | [_chat_compaction/AGENTS.md](_chat_compaction/AGENTS.md) | Full-chat compaction into a summary message. |
74 +| [_chat_naming/AGENTS.md](_chat_naming/AGENTS.md) | Built-in manual and Utility Model-assisted chat naming. |
75 | [_commands/AGENTS.md](_commands/AGENTS.md) | Built-in slash command manager, command file discovery, and chat composer slash picker. |
76 | [_code_execution/AGENTS.md](_code_execution/AGENTS.md) | Terminal, Python, and Node.js execution tools and shell runtimes. |
77 | [_desktop/AGENTS.md](_desktop/AGENTS.md) | Linux desktop runtime, sessions, and desktop surface. |
@@ -87,6 +89,7 @@ Direct child DOX files:
89 | [_office/AGENTS.md](_office/AGENTS.md) | LibreOffice office artifacts and office canvas sessions. |
90 | [_onboarding/AGENTS.md](_onboarding/AGENTS.md) | First-time model onboarding wizard. |
91 | [_orchestrator/AGENTS.md](_orchestrator/AGENTS.md) | External terminal coding-agent orchestration skill, adapter status, and settings UI. |
92 +| [_pin_to_top/AGENTS.md](_pin_to_top/AGENTS.md) | Built-in chat and task sidebar pinning. |
93 | [_plugin_installer/AGENTS.md](_plugin_installer/AGENTS.md) | Plugin install and update flows from ZIP, Git, and Plugin Index. |
94 | [_plugin_scan/AGENTS.md](_plugin_scan/AGENTS.md) | LLM-guided security scanner for third-party plugins. |
95 | [_plugin_validator/AGENTS.md](_plugin_validator/AGENTS.md) | Plugin manifest, structure, convention, and security validator. |
plugins/_browser/extensions/webui/get_tool_message_handler/browser-tool-handler.js
+1 -1
@@ -525,8 +525,8 @@ function drawBrowserTool({
525 buildDetailPayload(args, { headerLabels }),
526 ),
527 ),
528 - createActionButton("speak", "", () => ttsService.speak(contentText)),
528 createActionButton("copy", "", () => copyToClipboard(contentText)),
529 + createActionButton("speak", "", () => ttsService.speak(contentText)),
530 );
531 }
532
plugins/_chat_naming/AGENTS.md new
+36
@@ -0,0 +1,36 @@
1 +# Chat Naming Plugin DOX
2 +
3 +## Purpose
4 +
5 +- Own manual and Utility Model-assisted naming for chats and scheduled-task rows.
6 +
7 +## Ownership
8 +
9 +- `helpers/naming.py` owns user-message selection, name generation, and persistence.
10 +- `extensions/python/monologue_start/` owns configured automatic naming.
11 +- `api/chat_name.py` owns modal reads, generation, and manual saves.
12 +- `webui/` and `extensions/webui/sidebar-row-actions-menu/` own the standard rename modal and row-menu action.
13 +- `prompts/` owns the Utility Model naming instructions.
14 +
15 +## Local Contracts
16 +
17 +- Automatic naming reads scoped plugin config through the active chat agent.
18 +- Utility Model input contains the current name and user messages only; assistant work and tool results are excluded.
19 +- The complete naming prompt must remain within 70% of the effective Utility Model context window, preserving the newest user context when trimming is required.
20 +- `once` names only unnamed user chats from their first user message; `always` considers the latest user message plus recent user context.
21 +- Generated names are concise and normalized before persistence.
22 +- Renaming a parallel child updates both its context name and sidebar label.
23 +- Manual task renames update both scheduler metadata and the task context name.
24 +
25 +## Work Guidance
26 +
27 +- Keep this behavior plugin-local; do not restore naming prompts or naming hooks to core extensions.
28 +- Use the shared modal stack and sidebar row-menu extension point.
29 +
30 +## Verification
31 +
32 +- Run `pytest plugins/_chat_naming/tests tests/test_sidebar_row_actions.py`.
33 +
34 +## Child DOX Index
35 +
36 +No child DOX files.
plugins/_chat_naming/README.md new
+5
@@ -0,0 +1,5 @@
1 +# Chat Naming
2 +
3 +Chat Naming adds a standard sidebar action for manually renaming chats and tasks. Its modal can ask the chat's configured Utility Model to suggest a concise name from recent user messages.
4 +
5 +Automatic naming is configured per project and agent profile. It can name an unnamed chat once from its first user message, or refresh the name after every user message using recent user-only context.
plugins/_chat_naming/api/__init__.py new
+1
@@ -0,0 +1 @@
1 +"""Chat naming API handlers."""
plugins/_chat_naming/api/chat_name.py new
+68
@@ -0,0 +1,68 @@
1 +from __future__ import annotations
2 +
3 +from agent import AgentContext
4 +from helpers.api import ApiHandler, Input, Output, Request, Response
5 +from helpers.persist_chat import save_tmp_chat
6 +from helpers.state_monitor_integration import mark_dirty_all
7 +from helpers.task_scheduler import TaskScheduler
8 +from plugins._chat_naming.helpers import naming
9 +
10 +
11 +class ChatName(ApiHandler):
12 + async def process(self, input: Input, request: Request) -> Output:
13 + del request
14 + action = str(input.get("action", "get") or "get").strip().lower()
15 + kind = str(input.get("kind", "chat") or "chat").strip().lower()
16 + item_id = str(input.get("item_id", "") or "").strip()
17 + if kind not in {"chat", "task"}:
18 + return Response("Invalid row kind.", 400)
19 + if not item_id:
20 + return Response("Missing chat or task ID.", 400)
21 +
22 + context = AgentContext.get(item_id)
23 + if not context:
24 + return Response("Chat context not found.", 404)
25 +
26 + try:
27 + if action == "get":
28 + current_name = await self._current_name(kind, item_id, context.name or "")
29 + return {"ok": True, "name": current_name}
30 + if action == "generate":
31 + current_name = await self._current_name(kind, item_id, context.name or "")
32 + name = await naming.generate_name(
33 + context.agent0,
34 + current_name=current_name,
35 + )
36 + return {"ok": True, "name": name}
37 + if action == "save":
38 + name = naming.normalize_manual_name(input.get("name", ""))
39 + if kind == "task":
40 + await self._save_task_name(item_id, name)
41 + context.name = name
42 + save_tmp_chat(context)
43 + mark_dirty_all(reason="plugins._chat_naming.save_task_name")
44 + else:
45 + naming.save_context_name(context.agent0, name)
46 + return {"ok": True, "name": name}
47 + return Response(f"Unknown action: {action}", 400)
48 + except ValueError as error:
49 + return Response(str(error), 400)
50 + except Exception as error:
51 + return Response(str(error), 500)
52 +
53 + async def _current_name(self, kind: str, item_id: str, fallback: str) -> str:
54 + if kind != "task":
55 + return fallback
56 + scheduler = TaskScheduler.get()
57 + await scheduler.reload()
58 + task = scheduler.get_task_by_uuid(item_id)
59 + if not task:
60 + raise ValueError("Scheduled task not found.")
61 + return str(task.name or fallback)
62 +
63 + async def _save_task_name(self, item_id: str, name: str) -> None:
64 + scheduler = TaskScheduler.get()
65 + await scheduler.reload()
66 + task = await scheduler.update_task(item_id, name=name)
67 + if not task:
68 + raise ValueError("Scheduled task not found.")
plugins/_chat_naming/default_config.yaml new
+2
@@ -0,0 +1,2 @@
1 +automatic_naming: true
2 +automatic_naming_mode: once
plugins/_chat_naming/extensions/python/monologue_start/_60_rename_chat.py new
+77
@@ -0,0 +1,77 @@
1 +import asyncio
2 +
3 +from agent import AgentContext, AgentContextType, LoopData
4 +from helpers.extension import Extension
5 +from helpers.notification import NotificationManager, NotificationPriority, NotificationType
6 +from plugins._chat_naming.helpers import naming
7 +
8 +
9 +class RenameChat(Extension):
10 + async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
11 + if not self.agent or self.agent is not self.agent.context.agent0:
12 + return
13 + if self.agent.context.type != AgentContextType.USER:
14 + return
15 +
16 + config = naming.get_config(self.agent)
17 + if not config["automatic_naming"]:
18 + return
19 +
20 + mode = config["automatic_naming_mode"]
21 + if mode == naming.MODE_ONCE and str(self.agent.context.name or "").strip():
22 + return
23 +
24 + messages = naming.get_user_messages(
25 + self.agent,
26 + limit=None if mode == naming.MODE_ONCE else naming.RECENT_USER_MESSAGES,
27 + )
28 + if not messages:
29 + return
30 + if mode == naming.MODE_ONCE:
31 + messages = messages[:1]
32 +
33 + asyncio.create_task(
34 + self.change_name(
35 + messages=messages,
36 + request_sequence=naming.latest_user_sequence(self.agent),
37 + only_if_unnamed=(mode == naming.MODE_ONCE),
38 + )
39 + )
40 +
41 + async def change_name(
42 + self,
43 + *,
44 + messages: list[str],
45 + request_sequence: int,
46 + only_if_unnamed: bool,
47 + ) -> None:
48 + if not self.agent:
49 + return
50 +
51 + try:
52 + new_name = await naming.generate_name(
53 + self.agent,
54 + user_messages=messages,
55 + current_name=self.agent.context.name,
56 + )
57 + if only_if_unnamed and str(self.agent.context.name or "").strip():
58 + return
59 + if naming.latest_user_sequence(self.agent) != request_sequence:
60 + return
61 + if AgentContext.get(self.agent.context.id) is not self.agent.context:
62 + return
63 + naming.save_context_name(self.agent, new_name)
64 + except Exception:
65 + NotificationManager.send_notification(
66 + type=NotificationType.ERROR,
67 + priority=NotificationPriority.NORMAL,
68 + title="Chat Naming Failed",
69 + message="Automatic chat naming failed because the Utility Model was not reachable.",
70 + detail=(
71 + "Automatic chat naming uses the Utility Model. Check Settings > Models > "
72 + "Utility Model, provider/API key, and network reachability."
73 + ),
74 + display_time=10,
75 + group="chat_naming",
76 + id=f"chat_naming_failed_{self.agent.context.id}",
77 + )
plugins/_chat_naming/extensions/webui/sidebar-row-actions-menu/rename.html new
+19
@@ -0,0 +1,19 @@
1 +<html>
2 +<head>
3 + <script type="module">
4 + import { store } from "/plugins/_chat_naming/webui/chat-naming-store.js";
5 + </script>
6 +</head>
7 +<body>
8 + <div x-data>
9 + <button type="button" class="dropdown-item" role="menuitem"
10 + @click="$store.chatNaming.openFromMenu(
11 + $store.sidebar.rowMenuOpenId,
12 + $store.sidebar.rowMenuKind,
13 + )">
14 + <span class="material-symbols-outlined">edit</span>
15 + <span x-text="$store.sidebar.rowMenuKind === 'task' ? 'Rename Task' : 'Rename Chat'"></span>
16 + </button>
17 + </div>
18 +</body>
19 +</html>
plugins/_chat_naming/helpers/__init__.py new
+1
@@ -0,0 +1 @@
1 +"""Chat naming helpers."""
plugins/_chat_naming/helpers/naming.py new
+190
@@ -0,0 +1,190 @@
1 +from __future__ import annotations
2 +
3 +import json
4 +from typing import Any
5 +
6 +from agent import Agent
7 +from helpers import persist_chat, plugins, tokens
8 +from helpers.state_monitor_integration import mark_dirty_all
9 +
10 +
11 +PLUGIN_NAME = "_chat_naming"
12 +MODE_ONCE = "once"
13 +MODE_ALWAYS = "always"
14 +RECENT_USER_MESSAGES = 4
15 +GENERATED_NAME_LIMIT = 40
16 +UTILITY_CONTEXT_INPUT_RATIO = 0.7
17 +
18 +
19 +def get_config(agent: Agent) -> dict[str, Any]:
20 + config = plugins.get_plugin_config(PLUGIN_NAME, agent=agent) or {}
21 + mode = str(config.get("automatic_naming_mode", MODE_ONCE) or MODE_ONCE)
22 + if mode not in {MODE_ONCE, MODE_ALWAYS}:
23 + mode = MODE_ONCE
24 + return {
25 + "automatic_naming": bool(config.get("automatic_naming", True)),
26 + "automatic_naming_mode": mode,
27 + }
28 +
29 +
30 +def get_user_messages(agent: Agent, *, limit: int | None = RECENT_USER_MESSAGES) -> list[str]:
31 + messages: list[str] = []
32 + for message in agent.history.all_messages():
33 + if message.ai:
34 + continue
35 + text = _user_message_text(message.content)
36 + if text:
37 + messages.append(text)
38 + return messages[-limit:] if limit else messages
39 +
40 +
41 +def latest_user_sequence(agent: Agent) -> int:
42 + for message in reversed(agent.history.all_messages()):
43 + if not message.ai and _user_message_text(message.content):
44 + return int(message.sequence or 0)
45 + return 0
46 +
47 +
48 +async def generate_name(
49 + agent: Agent,
50 + *,
51 + user_messages: list[str] | None = None,
52 + current_name: str | None = None,
53 +) -> str:
54 + selected_messages = user_messages or get_user_messages(agent)
55 + if not selected_messages:
56 + raise ValueError("This chat has no user messages to name yet.")
57 +
58 + from plugins._model_config.helpers.model_config import get_utility_model_config
59 +
60 + utility_config = get_utility_model_config(agent)
61 + context_length = int(utility_config.get("ctx_length", 128000) or 128000)
62 + if context_length <= 0:
63 + context_length = 128000
64 + input_budget = max(int(context_length * UTILITY_CONTEXT_INPUT_RATIO), 1)
65 +
66 + system_prompt = agent.read_prompt("fw.chat_naming.system.md")
67 + resolved_name = (
68 + current_name if current_name is not None else agent.context.name
69 + ) or "(unnamed)"
70 + prompt_without_messages = agent.read_prompt(
71 + "fw.chat_naming.message.md",
72 + current_name=resolved_name,
73 + user_messages="",
74 + )
75 + fixed_tokens = _estimated_input_tokens(system_prompt, prompt_without_messages)
76 + message_budget = input_budget - fixed_tokens
77 + if message_budget <= 0:
78 + raise ValueError("The Utility Model context window is too small for chat naming.")
79 +
80 + message_text = _fit_user_messages(selected_messages, message_budget)
81 + user_prompt = agent.read_prompt(
82 + "fw.chat_naming.message.md",
83 + current_name=resolved_name,
84 + user_messages=message_text,
85 + )
86 +
87 + estimated_tokens = _estimated_input_tokens(system_prompt, user_prompt)
88 + while estimated_tokens > input_budget and message_text:
89 + excess = estimated_tokens - input_budget
90 + reduced_budget = max(
91 + tokens.approximate_tokens(message_text) - excess - 1,
92 + 1,
93 + )
94 + trimmed = _trim_to_estimated_tokens(message_text, reduced_budget)
95 + if trimmed == message_text:
96 + break
97 + message_text = trimmed
98 + user_prompt = agent.read_prompt(
99 + "fw.chat_naming.message.md",
100 + current_name=resolved_name,
101 + user_messages=message_text,
102 + )
103 + estimated_tokens = _estimated_input_tokens(system_prompt, user_prompt)
104 +
105 + if estimated_tokens > input_budget:
106 + raise ValueError("Chat naming input exceeds the Utility Model context budget.")
107 +
108 + response = await agent.call_utility_model(
109 + system=system_prompt,
110 + message=user_prompt,
111 + background=True,
112 + )
113 + name = normalize_generated_name(response)
114 + if not name:
115 + raise ValueError("The Utility Model did not return a chat name.")
116 + return name
117 +
118 +
119 +def save_context_name(agent: Agent, name: str) -> str:
120 + normalized = normalize_manual_name(name)
121 + agent.context.name = normalized
122 + if "parent_context_label" in agent.context.output_data:
123 + agent.context.output_data["parent_context_label"] = normalized
124 + persist_chat.save_tmp_chat(agent.context)
125 + mark_dirty_all(reason="plugins._chat_naming.save_context_name")
126 + return normalized
127 +
128 +
129 +def normalize_generated_name(value: object) -> str:
130 + name = " ".join(str(value or "").split()).strip(" \"'`#")
131 + if len(name) > GENERATED_NAME_LIMIT:
132 + name = name[: GENERATED_NAME_LIMIT - 3].rstrip() + "..."
133 + return name
134 +
135 +
136 +def normalize_manual_name(value: object) -> str:
137 + name = " ".join(str(value or "").split())
138 + if not name:
139 + raise ValueError("Name is required.")
140 + if len(name) > 200:
141 + raise ValueError("Name must be 200 characters or fewer.")
142 + return name
143 +
144 +
145 +def _user_message_text(content: object) -> str:
146 + if not isinstance(content, dict):
147 + return ""
148 + for key in ("user_message", "user_intervention"):
149 + value = content.get(key)
150 + if isinstance(value, str):
151 + return value.strip()
152 + if value:
153 + return json.dumps(value, ensure_ascii=False)
154 + return ""
155 +
156 +
157 +def _fit_user_messages(messages: list[str], token_budget: int) -> str:
158 + selected: list[str] = []
159 + for message in reversed(messages):
160 + candidate = [message, *selected]
161 + text = _format_user_messages(candidate)
162 + if tokens.approximate_tokens(text) <= token_budget:
163 + selected = candidate
164 + continue
165 + return _trim_to_estimated_tokens(text, token_budget)
166 + return _format_user_messages(selected)
167 +
168 +
169 +def _estimated_input_tokens(system_prompt: str, user_prompt: str) -> int:
170 + return tokens.approximate_tokens(system_prompt) + tokens.approximate_tokens(
171 + user_prompt
172 + )
173 +
174 +
175 +def _format_user_messages(messages: list[str]) -> str:
176 + return "\n\n".join(
177 + f"{index}. {text}" for index, text in enumerate(messages, start=1)
178 + )
179 +
180 +
181 +def _trim_to_estimated_tokens(text: str, token_budget: int) -> str:
182 + if tokens.approximate_tokens(text) <= token_budget:
183 + return text
184 +
185 + exact_budget = max(int(token_budget / tokens.APPROX_BUFFER) - 1, 1)
186 + trimmed = tokens.trim_to_tokens(text, exact_budget, "end")
187 + while tokens.approximate_tokens(trimmed) > token_budget and exact_budget > 1:
188 + exact_budget = max(int(exact_budget * 0.8), 1)
189 + trimmed = tokens.trim_to_tokens(text, exact_budget, "end")
190 + return trimmed
plugins/_chat_naming/plugin.yaml new
+9
@@ -0,0 +1,9 @@
1 +name: _chat_naming
2 +title: Chat Naming
3 +description: Rename chats manually or generate concise names with the Utility Model.
4 +version: 1.0.0
5 +settings_sections:
6 + - agent
7 +per_project_config: true
8 +per_agent_config: true
9 +always_enabled: true
plugins/_chat_naming/prompts/fw.chat_naming.message.md new
+8
@@ -0,0 +1,8 @@
1 +# Instruction
2 +Provide a chat name for the following conversation.
3 +
4 +# Current chat name
5 +{{current_name}}
6 +
7 +# Recent user messages
8 +{{user_messages}}
plugins/_chat_naming/prompts/fw.chat_naming.system.md new
+19
@@ -0,0 +1,19 @@
1 +# AI role
2 +- You are a chat naming assistant.
3 +- Suggest a short name for the conversation.
4 +
5 +# Input
6 +- You receive the current chat name and recent user messages only.
7 +- The most recent message is most important, but use earlier messages to resolve short follow-ups such as "okay, do it."
8 +
9 +# Output
10 +- Respond with a short chat name of 1-3 words.
11 +- Consider the current name and change it only when the conversation topic has changed.
12 +- Return only the name, without formatting, an introduction, or additional text.
13 +- Maintain proper capitalization.
14 +
15 +# Examples
16 +Database setup
17 +Requirements installation
18 +Merging documents
19 +Image analysis
plugins/_chat_naming/tests/test_chat_naming.py new
+291
@@ -0,0 +1,291 @@
1 +from types import SimpleNamespace
2 +
3 +import pytest
4 +
5 +from agent import AgentContextType
6 +from plugins._chat_naming.extensions.python.monologue_start import _60_rename_chat as rename_chat
7 +from plugins._chat_naming.helpers import naming
8 +
9 +
10 +pytestmark = pytest.mark.asyncio
11 +
12 +
13 +class _Message:
14 + def __init__(self, content, *, ai=False, sequence=0):
15 + self.content = content
16 + self.ai = ai
17 + self.sequence = sequence
18 +
19 +
20 +class _History:
21 + def __init__(self, messages):
22 + self._messages = messages
23 +
24 + def all_messages(self):
25 + return list(self._messages)
26 +
27 +
28 +class _Agent:
29 + def __init__(self, messages, *, name="", response="Generated Name"):
30 + self.context = SimpleNamespace(
31 + id="ctx-naming",
32 + name=name,
33 + type=AgentContextType.USER,
34 + )
35 + self.context.agent0 = self
36 + self.history = _History(messages)
37 + self.config = SimpleNamespace(profile="agent0")
38 + self._response = response
39 + self.utility_calls = []
40 +
41 + def read_prompt(self, name, **kwargs):
42 + return f"{name}:{kwargs}"
43 +
44 + async def call_utility_model(self, **kwargs):
45 + self.utility_calls.append(kwargs)
46 + return self._response
47 +
48 +
49 +async def test_user_message_selection_excludes_assistant_work_and_tool_results():
50 + agent = _Agent(
51 + [
52 + _Message({"user_message": "Plan a launch"}, sequence=1),
53 + _Message({"tool_name": "search", "tool_result": "internal work"}, sequence=2),
54 + _Message("assistant response", ai=True, sequence=3),
55 + _Message({"user_intervention": "Okay, do it"}, sequence=4),
56 + ]
57 + )
58 +
59 + assert naming.get_user_messages(agent) == ["Plan a launch", "Okay, do it"]
60 + assert naming.latest_user_sequence(agent) == 4
61 +
62 +
63 +async def test_once_mode_uses_first_message_and_does_not_override_a_name(monkeypatch):
64 + scheduled = []
65 + agent = _Agent(
66 + [
67 + _Message({"user_message": "First request"}, sequence=1),
68 + _Message({"user_message": "Second request"}, sequence=2),
69 + ]
70 + )
71 + monkeypatch.setattr(
72 + rename_chat.naming,
73 + "get_config",
74 + lambda _agent: {"automatic_naming": True, "automatic_naming_mode": "once"},
75 + )
76 + monkeypatch.setattr(rename_chat.asyncio, "create_task", lambda coro: scheduled.append(coro))
77 +
78 + await rename_chat.RenameChat(agent=agent).execute()
79 + assert len(scheduled) == 1
80 +
81 + captured = {}
82 +
83 + async def generate(_agent, **kwargs):
84 + captured.update(kwargs)
85 + return "First Request"
86 +
87 + saved = []
88 + monkeypatch.setattr(rename_chat.naming, "generate_name", generate)
89 + monkeypatch.setattr(
90 + rename_chat.naming,
91 + "save_context_name",
92 + lambda _agent, name: saved.append(name),
93 + )
94 + monkeypatch.setattr(rename_chat.AgentContext, "get", lambda _id: agent.context)
95 + await scheduled.pop()
96 +
97 + assert captured["user_messages"] == ["First request"]
98 + assert saved == ["First Request"]
99 +
100 + agent.context.name = "Manual Name"
101 + await rename_chat.RenameChat(agent=agent).execute()
102 + assert scheduled == []
103 +
104 +
105 +async def test_always_mode_passes_recent_user_context(monkeypatch):
106 + messages = [
107 + _Message({"user_message": f"Message {number}"}, sequence=number)
108 + for number in range(1, 7)
109 + ]
110 + agent = _Agent(messages, name="Existing Name")
111 + scheduled = []
112 + monkeypatch.setattr(
113 + rename_chat.naming,
114 + "get_config",
115 + lambda _agent: {"automatic_naming": True, "automatic_naming_mode": "always"},
116 + )
117 + monkeypatch.setattr(rename_chat.asyncio, "create_task", lambda coro: scheduled.append(coro))
118 +
119 + await rename_chat.RenameChat(agent=agent).execute()
120 + captured = {}
121 +
122 + async def generate(_agent, **kwargs):
123 + captured.update(kwargs)
124 + return "Current Topic"
125 +
126 + monkeypatch.setattr(rename_chat.naming, "generate_name", generate)
127 + monkeypatch.setattr(rename_chat.naming, "save_context_name", lambda *_args: None)
128 + monkeypatch.setattr(rename_chat.AgentContext, "get", lambda _id: agent.context)
129 + await scheduled.pop()
130 +
131 + assert captured["user_messages"] == [
132 + "Message 3",
133 + "Message 4",
134 + "Message 5",
135 + "Message 6",
136 + ]
137 + assert captured["current_name"] == "Existing Name"
138 +
139 +
140 +async def test_rename_failure_sends_scoped_utility_model_notification(monkeypatch):
141 + sent = []
142 + agent = _Agent([_Message({"user_message": "Plan a launch"}, sequence=1)])
143 +
144 + async def fail(*_args, **_kwargs):
145 + raise RuntimeError("offline")
146 +
147 + monkeypatch.setattr(rename_chat.naming, "generate_name", fail)
148 + monkeypatch.setattr(
149 + rename_chat.NotificationManager,
150 + "send_notification",
151 + lambda **kwargs: sent.append(kwargs),
152 + )
153 +
154 + await rename_chat.RenameChat(agent=agent).change_name(
155 + messages=["Plan a launch"],
156 + request_sequence=1,
157 + only_if_unnamed=True,
158 + )
159 +
160 + assert len(sent) == 1
161 + assert sent[0]["type"] == rename_chat.NotificationType.ERROR
162 + assert sent[0]["title"] == "Chat Naming Failed"
163 + assert sent[0]["id"] == "chat_naming_failed_ctx-naming"
164 +
165 +
166 +async def test_generated_name_is_normalized_and_bounded(monkeypatch):
167 + agent = _Agent(
168 + [_Message({"user_message": "Plan the release"}, sequence=1)],
169 + response=' "A very long generated release planning title that should be shortened" ',
170 + )
171 + monkeypatch.setattr(
172 + "plugins._model_config.helpers.model_config.get_utility_model_config",
173 + lambda _agent: {"ctx_length": 1000},
174 + )
175 +
176 + result = await naming.generate_name(agent)
177 +
178 + assert len(result) <= naming.GENERATED_NAME_LIMIT
179 + assert result.endswith("...")
180 + assert agent.utility_calls[0]["background"] is True
181 +
182 +
183 +async def test_naming_input_stays_within_utility_context_budget(monkeypatch):
184 + agent = _Agent(
185 + [
186 + _Message({"user_message": "older context " * 2000}, sequence=1),
187 + _Message({"user_message": "LATEST_CONTEXT " * 2000}, sequence=2),
188 + ],
189 + response="Budgeted Name",
190 + )
191 + monkeypatch.setattr(
192 + "plugins._model_config.helpers.model_config.get_utility_model_config",
193 + lambda _agent: {"ctx_length": 300},
194 + )
195 +
196 + await naming.generate_name(agent)
197 +
198 + call = agent.utility_calls[0]
199 + estimated_tokens = naming.tokens.approximate_tokens(
200 + call["system"]
201 + ) + naming.tokens.approximate_tokens(call["message"])
202 + assert estimated_tokens <= int(300 * naming.UTILITY_CONTEXT_INPUT_RATIO)
203 + assert "LATEST_CONTEXT" in call["message"]
204 + assert "older context" not in call["message"]
205 +
206 +
207 +async def test_manual_api_generates_and_saves_with_target_chat_agent(monkeypatch):
208 + from plugins._chat_naming.api import chat_name
209 +
210 + agent = _Agent([_Message({"user_message": "Name this chat"}, sequence=1)])
211 + monkeypatch.setattr(chat_name.AgentContext, "get", lambda _id: agent.context)
212 +
213 + async def generate(target_agent, **kwargs):
214 + assert target_agent is agent
215 + assert kwargs["current_name"] == ""
216 + return "Generated Chat"
217 +
218 + saved = []
219 + monkeypatch.setattr(chat_name.naming, "generate_name", generate)
220 + monkeypatch.setattr(
221 + chat_name.naming,
222 + "save_context_name",
223 + lambda target_agent, name: saved.append((target_agent, name)),
224 + )
225 + handler = object.__new__(chat_name.ChatName)
226 +
227 + generated = await handler.process(
228 + {"action": "generate", "kind": "chat", "item_id": agent.context.id},
229 + None,
230 + )
231 + renamed = await handler.process(
232 + {
233 + "action": "save",
234 + "kind": "chat",
235 + "item_id": agent.context.id,
236 + "name": " Manual Chat ",
237 + },
238 + None,
239 + )
240 +
241 + assert generated == {"ok": True, "name": "Generated Chat"}
242 + assert renamed == {"ok": True, "name": "Manual Chat"}
243 + assert saved == [(agent, "Manual Chat")]
244 +
245 +
246 +async def test_manual_task_rename_updates_scheduler_and_context(monkeypatch):
247 + from plugins._chat_naming.api import chat_name
248 +
249 + agent = _Agent([_Message({"user_message": "Run a report"}, sequence=1)], name="Old Task")
250 +
251 + class _Scheduler:
252 + def __init__(self):
253 + self.updated = []
254 +
255 + async def reload(self):
256 + return None
257 +
258 + async def update_task(self, item_id, **kwargs):
259 + self.updated.append((item_id, kwargs))
260 + return SimpleNamespace(name=kwargs["name"])
261 +
262 + scheduler = _Scheduler()
263 + saved = []
264 + dirty = []
265 + monkeypatch.setattr(chat_name.AgentContext, "get", lambda _id: agent.context)
266 + monkeypatch.setattr(chat_name.TaskScheduler, "get", lambda: scheduler)
267 + monkeypatch.setattr(chat_name, "save_tmp_chat", lambda context: saved.append(context.name))
268 + monkeypatch.setattr(chat_name, "mark_dirty_all", lambda *, reason: dirty.append(reason))
269 +
270 + response = await object.__new__(chat_name.ChatName).process(
271 + {
272 + "action": "save",
273 + "kind": "task",
274 + "item_id": agent.context.id,
275 + "name": "Daily Report",
276 + },
277 + None,
278 + )
279 +
280 + assert response == {"ok": True, "name": "Daily Report"}
281 + assert scheduler.updated == [(agent.context.id, {"name": "Daily Report"})]
282 + assert agent.context.name == "Daily Report"
283 + assert saved == ["Daily Report"]
284 + assert dirty == ["plugins._chat_naming.save_task_name"]
285 +
286 +
287 +async def test_chat_naming_endpoints_keep_default_auth_and_csrf_protection():
288 + from plugins._chat_naming.api.chat_name import ChatName
289 +
290 + assert ChatName.requires_auth() is True
291 + assert ChatName.requires_csrf() is True
plugins/_chat_naming/webui/chat-naming-store.js new
+148
@@ -0,0 +1,148 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import { openModal, closeModal } from "/js/modals.js";
4 +import {
5 + toastFrontendError,
6 + toastFrontendSuccess,
7 +} from "/components/notifications/notification-store.js";
8 +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
9 +import { store as sidebarStore } from "/components/sidebar/sidebar-store.js";
10 +import { store as tasksStore } from "/components/sidebar/tasks/tasks-store.js";
11 +
12 +const PLUGIN_ID = "_chat_naming";
13 +const MODAL_PATH = `/plugins/${PLUGIN_ID}/webui/rename.html`;
14 +
15 +const model = {
16 + itemId: "",
17 + kind: "chat",
18 + name: "",
19 + loading: false,
20 + generating: false,
21 + saving: false,
22 +
23 + async openFromMenu(menuId, kind) {
24 + const prefix = `${kind}:`;
25 + if (typeof menuId !== "string" || !menuId.startsWith(prefix)) return;
26 + this.itemId = menuId.slice(prefix.length);
27 + this.kind = kind;
28 + this.name = this.localName();
29 + sidebarStore.rowMenuClose();
30 + await openModal(MODAL_PATH);
31 + },
32 +
33 + async onOpen() {
34 + if (!this.itemId) return;
35 + this.loading = true;
36 + try {
37 + const response = await this.call("get");
38 + this.name = response?.name || "";
39 + } catch (error) {
40 + void toastFrontendError(error?.message || "Failed to load the name.", "Chat Naming");
41 + } finally {
42 + this.loading = false;
43 + }
44 + },
45 +
46 + async generate() {
47 + if (!this.itemId || this.generating) return;
48 + this.generating = true;
49 + try {
50 + const response = await this.call("generate");
51 + this.name = response?.name || this.name;
52 + } catch (error) {
53 + void toastFrontendError(error?.message || "Failed to generate a name.", "Chat Naming");
54 + } finally {
55 + this.generating = false;
56 + }
57 + },
58 +
59 + async openSettings() {
60 + try {
61 + const { store } = await import("/components/plugins/plugin-settings-store.js");
62 + const item = this.localItem();
63 + await store.openConfig(
64 + PLUGIN_ID,
65 + item?.project?.name || "",
66 + item?.agent_profile || "",
67 + );
68 + } catch (error) {
69 + void toastFrontendError(
70 + error?.message || "Failed to open settings.",
71 + "Chat Naming",
72 + );
73 + }
74 + },
75 +
76 + async save() {
77 + const name = this.name.trim();
78 + if (!this.itemId || !name || this.saving) return;
79 + this.saving = true;
80 + try {
81 + const response = await this.call("save", { name });
82 + this.name = response?.name || name;
83 + this.updateLocalName(this.name);
84 + await closeModal(MODAL_PATH);
85 + void toastFrontendSuccess(
86 + this.kind === "task" ? "Task renamed." : "Chat renamed.",
87 + "Chat Naming",
88 + );
89 + } catch (error) {
90 + void toastFrontendError(error?.message || "Failed to save the name.", "Chat Naming");
91 + } finally {
92 + this.saving = false;
93 + }
94 + },
95 +
96 + close() {
97 + return closeModal(MODAL_PATH);
98 + },
99 +
100 + call(action, extra = {}) {
101 + return callJsonApi(`/plugins/${PLUGIN_ID}/chat_name`, {
102 + action,
103 + kind: this.kind,
104 + item_id: this.itemId,
105 + ...extra,
106 + });
107 + },
108 +
109 + localName() {
110 + const item = this.localItem();
111 + return this.kind === "task" ? item?.task_name || "" : item?.name || "";
112 + },
113 +
114 + localItem() {
115 + return this.kind === "task"
116 + ? tasksStore.tasks.find((task) => task.id === this.itemId)
117 + : chatsStore.contexts.find((context) => context.id === this.itemId);
118 + },
119 +
120 + updateLocalName(name) {
121 + if (this.kind === "task") {
122 + tasksStore.tasks = tasksStore.tasks.map((task) =>
123 + task.id === this.itemId ? { ...task, name, task_name: name } : task,
124 + );
125 + return;
126 + }
127 + chatsStore.contexts = chatsStore.contexts.map((context) =>
128 + context.id === this.itemId
129 + ? {
130 + ...context,
131 + name,
132 + ...(context.parent_context_id ? { parent_context_label: name } : {}),
133 + }
134 + : context,
135 + );
136 + if (chatsStore.selectedContext?.id === this.itemId) {
137 + chatsStore.selectedContext = {
138 + ...chatsStore.selectedContext,
139 + name,
140 + ...(chatsStore.selectedContext.parent_context_id
141 + ? { parent_context_label: name }
142 + : {}),
143 + };
144 + }
145 + },
146 +};
147 +
148 +export const store = createStore("chatNaming", model);
plugins/_chat_naming/webui/config.html new
+51
@@ -0,0 +1,51 @@
1 +<html>
2 +<head><title>Chat Naming</title></head>
3 +<body>
4 + <div x-data>
5 + <template x-if="config">
6 + <div>
7 + <div class="section-title">Chat Naming</div>
8 + <div class="section-description">
9 + Configure automatic names for chats in this project and agent profile.
10 + </div>
11 +
12 + <div class="field">
13 + <div class="field-label">
14 + <div class="field-title">Automatic chat naming</div>
15 + <div class="field-description">
16 + Use the Utility Model to generate names for chats.
17 + </div>
18 + </div>
19 + <div class="field-control">
20 + <label class="toggle">
21 + <input type="checkbox" x-model="config.automatic_naming"
22 + x-init="if (config.automatic_naming == null) config.automatic_naming = true">
23 + <span class="toggler"></span>
24 + </label>
25 + </div>
26 + </div>
27 +
28 + <div class="field" x-show="config.automatic_naming" x-transition>
29 + <div class="field-label">
30 + <div class="field-title">Automatic naming method</div>
31 + <div class="field-description" x-show="config.automatic_naming_mode === 'once'">
32 + Rename only when the chat has no name, using its first user message.
33 + </div>
34 + <div class="field-description" x-show="config.automatic_naming_mode === 'always'">
35 + Refresh the name after every user message so it stays in sync with the conversation.
36 + </div>
37 + </div>
38 + <div class="field-control">
39 + <select x-model="config.automatic_naming_mode"
40 + x-init="if (!['once', 'always'].includes(config.automatic_naming_mode))
41 + config.automatic_naming_mode = 'once'">
42 + <option value="once">Only once</option>
43 + <option value="always">After every user message</option>
44 + </select>
45 + </div>
46 + </div>
47 + </div>
48 + </template>
49 + </div>
50 +</body>
51 +</html>
plugins/_chat_naming/webui/rename.html new
+91
@@ -0,0 +1,91 @@
1 +<html>
2 +<head>
3 + <title>Rename</title>
4 + <script type="module">
5 + import { store } from "/plugins/_chat_naming/webui/chat-naming-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.chatNaming">
11 + <div class="chat-naming-modal"
12 + x-init="await $store.chatNaming.onOpen(); $nextTick(() => $refs.nameInput?.focus())">
13 + <label class="chat-naming-label" for="chat-naming-input">Name</label>
14 + <input id="chat-naming-input" type="text" x-ref="nameInput"
15 + x-model="$store.chatNaming.name"
16 + @keydown.enter.prevent="$store.chatNaming.save()"
17 + :disabled="$store.chatNaming.loading || $store.chatNaming.saving"
18 + maxlength="200" autocomplete="off">
19 + </div>
20 + </template>
21 + </div>
22 +
23 + <template x-if="$store.chatNaming">
24 + <div class="modal-footer" data-modal-footer>
25 + <div class="chat-naming-secondary-actions">
26 + <button type="button" class="btn"
27 + @click="$store.chatNaming.generate()"
28 + :disabled="$store.chatNaming.loading || $store.chatNaming.generating
29 + || $store.chatNaming.saving">
30 + <span class="material-symbols-outlined"
31 + x-text="$store.chatNaming.generating ? 'progress_activity' : 'auto_awesome'"></span>
32 + <span x-text="$store.chatNaming.generating ? 'Generating...' : 'Generate'"></span>
33 + </button>
34 + <button type="button" class="btn"
35 + @click="$store.chatNaming.openSettings()"
36 + :disabled="$store.chatNaming.loading || $store.chatNaming.saving">
37 + <span class="material-symbols-outlined">settings</span>
38 + <span>Settings</span>
39 + </button>
40 + </div>
41 + <button type="button" class="btn btn-ok"
42 + @click="$store.chatNaming.save()"
43 + :disabled="$store.chatNaming.loading || $store.chatNaming.saving
44 + || !$store.chatNaming.name.trim()">
45 + Save
46 + </button>
47 + <button type="button" class="btn btn-cancel"
48 + @click="$store.chatNaming.close()" :disabled="$store.chatNaming.saving">
49 + Cancel
50 + </button>
51 + </div>
52 + </template>
53 +
54 + <style>
55 + .chat-naming-modal {
56 + display: flex;
57 + flex-direction: column;
58 + gap: var(--spacing-sm);
59 + padding: 1rem;
60 + }
61 +
62 + .chat-naming-label {
63 + color: var(--color-text);
64 + font-weight: 600;
65 + }
66 +
67 + .chat-naming-modal input {
68 + width: 100%;
69 + }
70 +
71 + .chat-naming-secondary-actions {
72 + display: inline-flex;
73 + align-items: center;
74 + gap: 0.75rem;
75 + margin-right: auto;
76 + }
77 +
78 + .chat-naming-secondary-actions .btn {
79 + display: inline-flex;
80 + align-items: center;
81 + justify-content: center;
82 + gap: 0.5rem;
83 + }
84 +
85 + .chat-naming-secondary-actions .material-symbols-outlined {
86 + font-size: 1.1rem;
87 + line-height: 1;
88 + }
89 + </style>
90 +</body>
91 +</html>
plugins/_pin_to_top/AGENTS.md new
+32
@@ -0,0 +1,32 @@
1 +# Pin to Top Plugin DOX
2 +
3 +## Purpose
4 +
5 +- Own built-in pinning for chat and scheduled-task rows in the sidebar.
6 +
7 +## Ownership
8 +
9 +- `plugin.yaml` owns the always-enabled plugin metadata.
10 +- `helpers/pins.py` and `api/` own persistent pin state and authenticated toggle/read endpoints.
11 +- `webui/pin-to-top-store.js` owns row ordering and divider callbacks.
12 +- `extensions/webui/sidebar-row-actions-menu/` owns the dropdown action.
13 +
14 +## Local Contracts
15 +
16 +- Pin state is separated into `chat` and `task` groups and stored under the `plugin_pin_to_top` persistent KVP key.
17 +- The plugin must register sidebar row-list callbacks; it must not patch chat/task stores or inject controls directly into rows.
18 +- Pinned items sort before unpinned items, older pins remain first, and existing order is preserved within the unpinned group.
19 +- The menu label and icon must reflect whether the active row is pinned.
20 +
21 +## Work Guidance
22 +
23 +- Keep the plugin always enabled and configuration-free.
24 +- Keep runtime state under `usr/` through the shared persistent KVP helper.
25 +
26 +## Verification
27 +
28 +- Run `pytest plugins/_pin_to_top/tests tests/test_sidebar_row_actions.py`.
29 +
30 +## Child DOX Index
31 +
32 +No child DOX files.
plugins/_pin_to_top/README.md new
+10
@@ -0,0 +1,10 @@
1 +# Pin to Top
2 +
3 +Built-in Agent Zero plugin for pinning chats and scheduled tasks to the top of their sidebar lists.
4 +
5 +- Adds a context-aware **Pin to Top** / **Unpin from Top** action to the sidebar row menu.
6 +- Keeps pinned items in pin order and preserves the existing order within the unpinned group.
7 +- Separates pinned and unpinned items with the standard sidebar divider.
8 +- Persists state in Agent Zero's user key-value storage.
9 +
10 +The plugin is always enabled and has no configuration screen.
plugins/_pin_to_top/__init__.py new
+1
@@ -0,0 +1 @@
1 +"""Built-in sidebar pinning plugin."""
plugins/_pin_to_top/api/__init__.py new
+1
@@ -0,0 +1 @@
1 +"""API handlers for the pin-to-top plugin."""
plugins/_pin_to_top/api/get_pins.py new
+9
@@ -0,0 +1,9 @@
1 +from helpers.api import ApiHandler, Input, Output, Request
2 +from plugins._pin_to_top.helpers.pins import get_pins
3 +
4 +
5 +class GetPins(ApiHandler):
6 + """Return persisted chat and task pins."""
7 +
8 + async def process(self, input: Input, request: Request) -> Output:
9 + return {"ok": True, "pins": get_pins()}
plugins/_pin_to_top/api/toggle_pin.py new
+21
@@ -0,0 +1,21 @@
1 +from helpers.api import ApiHandler, Input, Output, Request, Response
2 +from plugins._pin_to_top.helpers.pins import toggle_pin
3 +
4 +
5 +class TogglePin(ApiHandler):
6 + """Toggle one chat or task pin."""
7 +
8 + async def process(self, input: Input, request: Request) -> Output:
9 + try:
10 + pinned, timestamp = toggle_pin(
11 + str(input.get("kind", "")),
12 + str(input.get("item_id", "")),
13 + )
14 + except ValueError as error:
15 + return Response(str(error), 400)
16 +
17 + return {
18 + "ok": True,
19 + "pinned": pinned,
20 + "timestamp": timestamp,
21 + }
plugins/_pin_to_top/extensions/webui/sidebar-row-actions-menu/pin-to-top.html new
+18
@@ -0,0 +1,18 @@
1 +<html>
2 +<head>
3 + <script type="module">
4 + import { store } from "/plugins/_pin_to_top/webui/pin-to-top-store.js";
5 + </script>
6 +</head>
7 +<body>
8 + <div x-data x-init="$store.pinToTop.init()">
9 + <button type="button" class="dropdown-item" role="menuitem"
10 + @click="$store.pinToTop.toggleFromMenu($store.sidebar.rowMenuOpenId, $store.sidebar.rowMenuKind); $store.sidebar.rowMenuClose()">
11 + <span class="material-symbols-outlined"
12 + x-text="$store.pinToTop.isMenuItemPinned($store.sidebar.rowMenuOpenId, $store.sidebar.rowMenuKind) ? 'keep_off' : 'push_pin'"></span>
13 + <span
14 + x-text="$store.pinToTop.isMenuItemPinned($store.sidebar.rowMenuOpenId, $store.sidebar.rowMenuKind) ? 'Unpin from Top' : 'Pin to Top'"></span>
15 + </button>
16 + </div>
17 +</body>
18 +</html>
plugins/_pin_to_top/helpers/__init__.py new
+1
@@ -0,0 +1 @@
1 +"""Persistence helpers for the pin-to-top plugin."""
plugins/_pin_to_top/helpers/pins.py new
+76
@@ -0,0 +1,76 @@
1 +from __future__ import annotations
2 +
3 +import threading
4 +import time
5 +from typing import Literal
6 +
7 +from helpers import kvp
8 +
9 +PinKind = Literal["chat", "task"]
10 +
11 +STORE_KEY = "plugin_pin_to_top"
12 +KINDS: tuple[PinKind, ...] = ("chat", "task")
13 +_lock = threading.RLock()
14 +
15 +
16 +def get_pins() -> dict[PinKind, dict[str, float]]:
17 + """Return normalized persisted pins grouped by sidebar list."""
18 + with _lock:
19 + return _normalize(kvp.get_persistent(STORE_KEY, {}))
20 +
21 +
22 +def toggle_pin(kind: str, item_id: str) -> tuple[bool, float]:
23 + """Toggle a pin and return its new state and timestamp."""
24 + normalized_kind = _require_kind(kind)
25 + normalized_id = _require_item_id(item_id)
26 +
27 + with _lock:
28 + pins = _normalize(kvp.get_persistent(STORE_KEY, {}))
29 + kind_pins = pins[normalized_kind]
30 + if normalized_id in kind_pins:
31 + del kind_pins[normalized_id]
32 + timestamp = 0.0
33 + pinned = False
34 + else:
35 + timestamp = time.time()
36 + kind_pins[normalized_id] = timestamp
37 + pinned = True
38 +
39 + kvp.set_persistent(STORE_KEY, pins)
40 + return pinned, timestamp
41 +
42 +
43 +def _normalize(value: object) -> dict[PinKind, dict[str, float]]:
44 + normalized: dict[PinKind, dict[str, float]] = {"chat": {}, "task": {}}
45 + if not isinstance(value, dict):
46 + return normalized
47 +
48 + for kind in KINDS:
49 + entries = value.get(kind)
50 + if not isinstance(entries, dict):
51 + continue
52 + for item_id, timestamp in entries.items():
53 + try:
54 + clean_timestamp = float(timestamp)
55 + except (TypeError, ValueError):
56 + continue
57 + clean_id = str(item_id).strip()
58 + if clean_id and clean_timestamp > 0:
59 + normalized[kind][clean_id] = clean_timestamp
60 +
61 + return normalized
62 +
63 +
64 +def _require_kind(kind: str) -> PinKind:
65 + if kind not in KINDS:
66 + raise ValueError("kind must be 'chat' or 'task'")
67 + return kind
68 +
69 +
70 +def _require_item_id(item_id: str) -> str:
71 + normalized = item_id.strip()
72 + if not normalized:
73 + raise ValueError("item_id is required")
74 + if len(normalized) > 512:
75 + raise ValueError("item_id is too long")
76 + return normalized
plugins/_pin_to_top/plugin.yaml new
+8
@@ -0,0 +1,8 @@
1 +name: _pin_to_top
2 +title: Pin to Top
3 +description: Pin chats and scheduled tasks to the top of their sidebar lists.
4 +version: 1.0.0
5 +settings_sections: []
6 +per_project_config: false
7 +per_agent_config: false
8 +always_enabled: true
plugins/_pin_to_top/tests/test_pins.py new
+61
@@ -0,0 +1,61 @@
1 +import asyncio
2 +
3 +import pytest
4 +
5 +from plugins._pin_to_top.api.get_pins import GetPins
6 +from plugins._pin_to_top.api.toggle_pin import TogglePin
7 +from plugins._pin_to_top.helpers import pins
8 +
9 +
10 +@pytest.fixture()
11 +def persistent_store(monkeypatch):
12 + values = {}
13 + monkeypatch.setattr(
14 + pins.kvp,
15 + "get_persistent",
16 + lambda key, default=None: values.get(key, default),
17 + )
18 + monkeypatch.setattr(
19 + pins.kvp,
20 + "set_persistent",
21 + lambda key, value: values.__setitem__(key, value),
22 + )
23 + return values
24 +
25 +
26 +def test_pins_are_persistent_and_separated_by_kind(monkeypatch, persistent_store):
27 + timestamps = iter((100.0, 200.0))
28 + monkeypatch.setattr(pins.time, "time", lambda: next(timestamps))
29 +
30 + assert pins.toggle_pin("chat", "chat-1") == (True, 100.0)
31 + assert pins.toggle_pin("task", "task-1") == (True, 200.0)
32 + assert pins.get_pins() == {
33 + "chat": {"chat-1": 100.0},
34 + "task": {"task-1": 200.0},
35 + }
36 +
37 + assert pins.toggle_pin("chat", "chat-1") == (False, 0.0)
38 + assert pins.get_pins()["chat"] == {}
39 +
40 +
41 +@pytest.mark.parametrize(
42 + ("kind", "item_id"),
43 + (("unknown", "item-1"), ("chat", ""), ("task", "x" * 513)),
44 +)
45 +def test_invalid_pin_input_is_rejected(kind, item_id, persistent_store):
46 + with pytest.raises(ValueError):
47 + pins.toggle_pin(kind, item_id)
48 +
49 +
50 +def test_toggle_api_returns_a_bad_request_for_invalid_input(persistent_store):
51 + response = asyncio.run(
52 + TogglePin(None, None).process({"kind": "unknown", "item_id": "item-1"}, None)
53 + )
54 +
55 + assert response.status_code == 400
56 +
57 +
58 +def test_pin_endpoints_keep_default_auth_and_csrf_protection():
59 + for handler in (GetPins, TogglePin):
60 + assert handler.requires_auth() is True
61 + assert handler.requires_csrf() is True
plugins/_pin_to_top/webui/pin-to-top-store.js new
+95
@@ -0,0 +1,95 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import { toastFrontendError } from "/components/notifications/notification-store.js";
4 +import { store as sidebarStore } from "/components/sidebar/sidebar-store.js";
5 +
6 +const PLUGIN_ID = "_pin_to_top";
7 +
8 +const model = {
9 + pins: { chat: {}, task: {} },
10 + _initialized: false,
11 +
12 + async init() {
13 + if (this._initialized) return;
14 + this._initialized = true;
15 +
16 + for (const kind of ["chat", "task"]) {
17 + sidebarStore.registerRowListExtension(kind, PLUGIN_ID, {
18 + sort: (items) => this.sortItems(kind, items),
19 + dividerBefore: (item, index, items) =>
20 + this.dividerBefore(kind, item, index, items),
21 + });
22 + }
23 +
24 + await this.loadPins();
25 + },
26 +
27 + async loadPins() {
28 + try {
29 + const response = await callJsonApi(`/plugins/${PLUGIN_ID}/get_pins`, {});
30 + this.pins = {
31 + chat: { ...(response?.pins?.chat || {}) },
32 + task: { ...(response?.pins?.task || {}) },
33 + };
34 + } catch (error) {
35 + void toastFrontendError(error?.message || "Failed to load pinned items.", "Pin to Top");
36 + }
37 + },
38 +
39 + async toggleFromMenu(menuId, kind) {
40 + const itemId = this.itemIdFromMenu(menuId, kind);
41 + if (!itemId) return;
42 +
43 + try {
44 + const response = await callJsonApi(`/plugins/${PLUGIN_ID}/toggle_pin`, {
45 + kind,
46 + item_id: itemId,
47 + });
48 + const kindPins = { ...(this.pins[kind] || {}) };
49 + if (response?.pinned) kindPins[itemId] = response.timestamp;
50 + else delete kindPins[itemId];
51 + this.pins = { ...this.pins, [kind]: kindPins };
52 + } catch (error) {
53 + void toastFrontendError(error?.message || "Failed to update the pin.", "Pin to Top");
54 + }
55 + },
56 +
57 + itemIdFromMenu(menuId, kind) {
58 + const prefix = `${kind}:`;
59 + return typeof menuId === "string" && menuId.startsWith(prefix)
60 + ? menuId.slice(prefix.length)
61 + : "";
62 + },
63 +
64 + isMenuItemPinned(menuId, kind) {
65 + return this.isPinned(kind, this.itemIdFromMenu(menuId, kind));
66 + },
67 +
68 + isPinned(kind, itemId) {
69 + return Object.prototype.hasOwnProperty.call(this.pins[kind] || {}, itemId);
70 + },
71 +
72 + sortItems(kind, items) {
73 + const kindPins = this.pins[kind] || {};
74 + return items
75 + .map((item, index) => ({ item, index }))
76 + .sort((left, right) => {
77 + const leftPin = kindPins[left.item.id];
78 + const rightPin = kindPins[right.item.id];
79 + const leftPinned = leftPin !== undefined;
80 + const rightPinned = rightPin !== undefined;
81 + if (leftPinned !== rightPinned) return leftPinned ? -1 : 1;
82 + if (leftPinned && leftPin !== rightPin) return leftPin - rightPin;
83 + return left.index - right.index;
84 + })
85 + .map(({ item }) => item);
86 + },
87 +
88 + dividerBefore(kind, item, index, items) {
89 + return index > 0
90 + && !this.isPinned(kind, item.id)
91 + && this.isPinned(kind, items[index - 1]?.id);
92 + },
93 +};
94 +
95 +export const store = createStore("pinToTop", model);
prompts/fw.rename_chat.msg.md deleted
-8
@@ -1,8 +0,0 @@
1 -# Instruction
2 -- provide a chat name for the following
3 -
4 -# Current chat name
5 -{{current_name}}
6 -
7 -# Chat history
8 -{{history}}
prompts/fw.rename_chat.sys.md deleted
-19
@@ -1,19 +0,0 @@
1 -# AI role
2 -- You are a chat naming assistant
3 -- Your role is to suggest a short chat name for the current conversation
4 -
5 -# Input
6 -- You are given the current chat name and current chat history
7 -
8 -# Output
9 -- Respond with a short chat name (1-3 words) based on the chat history
10 -- Consider current chat name and only change it when the conversation topic has changed
11 -- Focus mainly on the end of the conversation history, there you can detect if the topic has changed
12 -- Only respond with the chat name without any formatting, intro or additional text
13 -- Maintain proper capitalization
14 -
15 -# Example responses
16 -Database setup
17 -Requirements installation
18 -Merging documents
19 -Image analysis
\ No newline at end of file
tests/test_chat_rename_extension.py deleted
-61
@@ -1,61 +0,0 @@
1 -from types import SimpleNamespace
2 -
3 -import pytest
4 -
5 -from extensions.python.monologue_start import _60_rename_chat as rename_chat
6 -from plugins._model_config.helpers import model_config
7 -
8 -
9 -pytestmark = pytest.mark.asyncio
10 -
11 -
12 -class _History:
13 - def output_text(self) -> str:
14 - return "User: Please help me plan the launch."
15 -
16 -
17 -class _Agent:
18 - def __init__(self, *, response: str | None = None, error: Exception | None = None):
19 - self.context = SimpleNamespace(id="ctx-rename", name="")
20 - self.history = _History()
21 - self._response = response
22 - self._error = error
23 -
24 - def read_prompt(self, name: str, **kwargs) -> str:
25 - return name
26 -
27 - async def call_utility_model(self, **kwargs) -> str:
28 - if self._error:
29 - raise self._error
30 - return self._response or ""
31 -
32 -
33 -async def test_rename_failure_sends_utility_model_error_notification(monkeypatch):
34 - sent: list[dict] = []
35 -
36 - monkeypatch.setattr(model_config, "get_utility_model_config", lambda agent: {"ctx_length": 1000})
37 - monkeypatch.setattr(rename_chat.NotificationManager, "send_notification", lambda **kwargs: sent.append(kwargs))
38 -
39 - await rename_chat.RenameChat(agent=_Agent(error=RuntimeError("offline"))).change_name()
40 -
41 - assert len(sent) == 1
42 - assert sent[0]["type"] == rename_chat.NotificationType.ERROR
43 - assert sent[0]["title"] == "Chat Rename Failed"
44 - assert "Utility Model was not reachable" in sent[0]["message"]
45 - assert sent[0]["id"] == "chat_rename_failed_ctx-rename"
46 -
47 -
48 -async def test_successful_rename_saves_clean_name_and_marks_state_dirty(monkeypatch):
49 - saved_names: list[str] = []
50 - dirty_reasons: list[str | None] = []
51 -
52 - monkeypatch.setattr(model_config, "get_utility_model_config", lambda agent: {"ctx_length": 1000})
53 - monkeypatch.setattr(rename_chat.persist_chat, "save_tmp_chat", lambda context: saved_names.append(context.name))
54 - monkeypatch.setattr(rename_chat, "mark_dirty_all", lambda *, reason=None: dirty_reasons.append(reason))
55 -
56 - agent = _Agent(response="\n\nLaunch Readiness Notes\n")
57 - await rename_chat.RenameChat(agent=agent).change_name()
58 -
59 - assert agent.context.name == "Launch Readiness Notes"
60 - assert saved_names == ["Launch Readiness Notes"]
61 - assert dirty_reasons == ["monologue_start.RenameChat.change_name"]
tests/test_sidebar_row_actions.py new
+93
@@ -0,0 +1,93 @@
1 +from pathlib import Path
2 +
3 +
4 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
5 +
6 +
7 +def test_chat_rows_have_hover_scoped_overflow_actions() -> None:
8 + html = (
9 + PROJECT_ROOT / "webui/components/sidebar/chats/chats-list.html"
10 + ).read_text(encoding="utf-8")
11 +
12 + assert html.count('aria-label="More chat actions"') == 2
13 + assert 'class="btn-icon-action chat-list-action-btn"' in html
14 + assert "<span class=\"material-symbols-outlined\">more_vert</span>" in html
15 + assert html.count("$store.sidebar.rowMenuToggle(") == 2
16 +
17 +
18 +def test_task_rows_have_overflow_actions_after_standard_buttons() -> None:
19 + html = (
20 + PROJECT_ROOT / "webui/components/sidebar/tasks/tasks-list.html"
21 + ).read_text(encoding="utf-8")
22 +
23 + delete_button = html.index('title="Delete task"')
24 + menu_button = html.index('aria-label="More task actions"')
25 + assert delete_button < menu_button
26 + assert "$store.sidebar.rowMenuToggle(`task:${task.id}`" in html
27 +
28 +
29 +def test_sidebar_uses_one_fixed_row_menu_with_standard_close_behavior() -> None:
30 + html = (
31 + PROJECT_ROOT / "webui/components/sidebar/left-sidebar.html"
32 + ).read_text(encoding="utf-8")
33 + store = (
34 + PROJECT_ROOT / "webui/components/sidebar/sidebar-store.js"
35 + ).read_text(encoding="utf-8")
36 +
37 + assert 'class="dropdown-menu sidebar-row-actions-menu"' in html
38 + assert '<x-extension id="sidebar-row-actions-menu"></x-extension>' in html
39 + assert '@click.window="$store.sidebar.rowMenuClick(' in html
40 + assert '@keydown.escape.window="$store.sidebar.rowMenuClose()"' in html
41 + assert "Rename Chat" not in html
42 + assert "position: fixed;" in html
43 + assert "z-index: 9999;" in html
44 + assert "rowMenuOpenId" in store
45 + assert "if (this.rowMenuOpenId === id)" in store
46 + assert "const openUp = spaceBelow < 96 && spaceAbove > spaceBelow;" in store
47 +
48 +
49 +def test_pin_plugin_contributes_the_menu_action_and_list_ordering() -> None:
50 + extension = (
51 + PROJECT_ROOT
52 + / "plugins/_pin_to_top/extensions/webui/sidebar-row-actions-menu/pin-to-top.html"
53 + ).read_text(encoding="utf-8")
54 + plugin_store = (
55 + PROJECT_ROOT / "plugins/_pin_to_top/webui/pin-to-top-store.js"
56 + ).read_text(encoding="utf-8")
57 + sidebar_store = (
58 + PROJECT_ROOT / "webui/components/sidebar/sidebar-store.js"
59 + ).read_text(encoding="utf-8")
60 +
61 + assert "'Unpin from Top' : 'Pin to Top'" in extension
62 + assert "'keep_off' : 'push_pin'" in extension
63 + assert "registerRowListExtension" in plugin_store
64 + assert "MutationObserver" not in plugin_store
65 + assert "applyContexts =" not in plugin_store
66 + assert "applyTasks =" not in plugin_store
67 + assert "registerRowListExtension(kind, name, extension)" in sidebar_store
68 + assert "hasRowDividerBefore(kind, item, index, rows)" in sidebar_store
69 +
70 +
71 +def test_chat_naming_plugin_contributes_rename_action_and_standard_modal() -> None:
72 + extension = (
73 + PROJECT_ROOT
74 + / "plugins/_chat_naming/extensions/webui/sidebar-row-actions-menu/rename.html"
75 + ).read_text(encoding="utf-8")
76 + modal = (
77 + PROJECT_ROOT / "plugins/_chat_naming/webui/rename.html"
78 + ).read_text(encoding="utf-8")
79 + store = (
80 + PROJECT_ROOT / "plugins/_chat_naming/webui/chat-naming-store.js"
81 + ).read_text(encoding="utf-8")
82 +
83 + assert "'Rename Task' : 'Rename Chat'" in extension
84 + assert 'data-modal-footer' in modal
85 + assert "chat-naming-secondary-actions" in modal
86 + assert "Settings" in modal
87 + assert "btn btn-ok" in modal
88 + assert "btn btn-cancel" in modal
89 + assert 'openModal(MODAL_PATH)' in store
90 + assert 'store.openConfig(' in store
91 + assert 'item?.project?.name' in store
92 + assert 'item?.agent_profile' in store
93 + assert 'callJsonApi(`/plugins/${PLUGIN_ID}/chat_name`' in store
tests/test_webui_extension_surfaces.py
+1
@@ -40,6 +40,7 @@ SURFACE_SCENARIOS: list[tuple[str, str]] = [
40 ("sidebar-chats-list-end", "webui/components/sidebar/chats/chats-list.html"),
41 ("sidebar-tasks-list-start", "webui/components/sidebar/tasks/tasks-list.html"),
42 ("sidebar-tasks-list-end", "webui/components/sidebar/tasks/tasks-list.html"),
43 + ("sidebar-row-actions-menu", "webui/components/sidebar/left-sidebar.html"),
44 ("sidebar-bottom-wrapper-start", "webui/components/sidebar/bottom/sidebar-bottom.html"),
45 ("sidebar-bottom-wrapper-end", "webui/components/sidebar/bottom/sidebar-bottom.html"),
46 ("chat-input-start", "webui/components/chat/input/chat-bar.html"),
tests/test_webui_message_ordering_static.py
+55 -5
@@ -1,3 +1,4 @@
1 +import re
2 from pathlib import Path
3
4
@@ -13,12 +14,61 @@ def test_full_log_replays_replace_existing_message_dom():
14 messages_js = read("webui", "js", "messages.js")
15
16 assert "snapshot.logs?.[0]?.no === 0" in index_js
16 - assert 'chatHistoryEl.innerHTML = "";' in index_js
17 - assert "messages.sort((a, b)" in messages_js
17 + assert "msgs.resetMessageRenderState();" in index_js
18 + assert "export function resetMessageRenderState" in messages_js
19 + assert "normalized.sort(" in messages_js
20
21
20 -def test_message_ordering_fix_does_not_add_renderer_cache_state():
22 +def test_message_ordering_uses_a_bounded_tail_first_renderer_cache():
23 messages_js = read("webui", "js", "messages.js")
24 + message_window_js = read("webui", "js", "message-window.js")
25
23 - assert "_messageCacheByNo" not in messages_js
24 - assert "resetMessageRenderState" not in messages_js
26 + assert 'from "./message-window.js"' in messages_js
27 + assert "_messageWindow.compactTailIfNeeded()" in messages_js
28 + assert "_messageWindow.visibleMessages()" in messages_js
29 + assert "class MessageWindow" in message_window_js
30 + assert "showTail()" in message_window_js
31 + assert "shiftOlder()" in message_window_js
32 + assert "shiftNewer()" in message_window_js
33 + assert "_messageWindowFollowTail" in messages_js
34 + assert "_messageWindow.showTail()" in messages_js
35 +
36 +
37 +def test_virtual_paging_uses_passive_loaders_and_cancels_stale_scrolling():
38 + messages_js = read("webui", "js", "messages.js")
39 + scroller_js = read("webui", "js", "scroller.js")
40 + messages_css = read("webui", "css", "messages.css")
41 +
42 + assert "createMessageWindowIndicator" in messages_js
43 + assert 'indicator.setAttribute("role", "status")' in messages_js
44 + assert "message-window-loader-bubble" in messages_css
45 + assert "@keyframes message-window-loader-dot" in messages_css
46 + assert "Load ${Math.min" not in messages_js
47 + assert "export function cancelPendingScroll" in scroller_js
48 + assert "cancelPendingScroll(history)" in messages_js
49 +
50 +
51 +def test_message_actions_put_copy_before_speak():
52 + sources = [
53 + read("webui", "js", "messages.js"),
54 + read(
55 + "plugins",
56 + "_browser",
57 + "extensions",
58 + "webui",
59 + "get_tool_message_handler",
60 + "browser-tool-handler.js",
61 + ),
62 + ]
63 +
64 + for source in sources:
65 + lines = source.splitlines()
66 + for index, line in enumerate(lines):
67 + if 'createActionButton("speak"' not in line:
68 + continue
69 + preceding_actions = [
70 + match.group(1)
71 + for candidate in lines[max(0, index - 12) : index]
72 + if (match := re.search(r'createActionButton\("(detail|copy|speak)"', candidate))
73 + ]
74 + assert preceding_actions and preceding_actions[-1] == "copy"
tests/test_webui_message_window.py new
+444
@@ -0,0 +1,444 @@
1 +import base64
2 +from pathlib import Path
3 +import shutil
4 +import subprocess
5 +
6 +import pytest
7 +
8 +
9 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 +MESSAGE_WINDOW_JS = PROJECT_ROOT / "webui" / "js" / "message-window.js"
11 +SCROLLER_JS = PROJECT_ROOT / "webui" / "js" / "scroller.js"
12 +PROCESS_GROUP_DOM_JS = (
13 + PROJECT_ROOT
14 + / "webui"
15 + / "components"
16 + / "messages"
17 + / "process-group"
18 + / "process-group-dom.js"
19 +)
20 +
21 +
22 +def test_message_window_keeps_tail_and_pages_bidirectionally():
23 + if not shutil.which("node"):
24 + pytest.skip("Node.js is required to execute the message-window regression.")
25 +
26 + source = MESSAGE_WINDOW_JS.read_bytes()
27 + module_url = "data:text/javascript;base64," + base64.b64encode(source).decode("ascii")
28 + script = f"""
29 +import {{ MessageWindow, classifyMessageRenderUnits }} from {module_url!r};
30 +
31 +function assert(condition, message) {{
32 + if (!condition) throw new Error(message);
33 +}}
34 +
35 +const pluginBackedGroup = [
36 + {{ no: 1, id: "step-1", type: "info" }},
37 + {{ no: 2, id: "step-2", type: "agent" }},
38 + {{ no: 3, id: "step-3", type: "code_exe" }},
39 + {{ no: 4, id: "step-4", type: "agent" }},
40 + {{ no: 5, id: "step-5", type: "code_exe" }},
41 + {{ no: 6, id: "response-1", type: "response", agentno: 0 }},
42 +];
43 +const pluginBackedUnits = classifyMessageRenderUnits(pluginBackedGroup);
44 +assert(
45 + pluginBackedUnits.every((unit) => unit.key === pluginBackedUnits[0].key),
46 + "code execution records must remain inside their surrounding process group",
47 +);
48 +assert(
49 + pluginBackedUnits.filter((unit) => unit.isStep).length === 5,
50 + "the root response must close the group without becoming a process step",
51 +);
52 +
53 +const prefixedUtilityGroup = [
54 + {{ no: 20, type: "util" }},
55 + {{ no: 21, type: "util" }},
56 + {{ no: 22, type: "agent", id: "agent-with-prefix" }},
57 + {{ no: 23, type: "response", id: "agent-with-prefix", agentno: 0 }},
58 +];
59 +const prefixedUtilityUnits = classifyMessageRenderUnits(prefixedUtilityGroup);
60 +assert(
61 + prefixedUtilityUnits.every((unit) => unit.key === prefixedUtilityUnits[0].key),
62 + "utilities immediately before a real process step must stay in that group",
63 +);
64 +
65 +const utilityOnlyResponse = [
66 + {{ no: 30, type: "util" }},
67 + {{ no: 31, type: "util" }},
68 + {{ no: 32, type: "response", id: "response-without-step", agentno: 0 }},
69 + {{ no: 33, type: "util" }},
70 + {{ no: 34, type: "user", id: "next-user" }},
71 +];
72 +const utilityOnlyUnits = classifyMessageRenderUnits(utilityOnlyResponse);
73 +assert(
74 + utilityOnlyUnits.every((unit) => unit.group === null && !unit.isStep),
75 + "orphan utilities must not create or reopen a process group around a response",
76 +);
77 +
78 +const sharedIdGroup = new MessageWindow({{ initialLimit: 60 }});
79 +sharedIdGroup.reset([
80 + {{ no: 1, id: "shared-run-id", type: "agent", content: "final generation" }},
81 + {{ no: 2, id: "shared-run-id", type: "response", content: "final response" }},
82 +]);
83 +assert(sharedIdGroup.size === 2, "a shared id must not merge GEN and response records");
84 +assert(
85 + sharedIdGroup.visibleMessages().map((entry) => entry.type).join(",") ===
86 + "agent,response",
87 + "replay must retain the final GEN immediately before its response",
88 +);
89 +sharedIdGroup.merge([
90 + {{ no: 2, id: "shared-run-id", type: "response", content: "updated response" }},
91 +]);
92 +assert(sharedIdGroup.size === 2, "updates to one typed record must not duplicate it");
93 +assert(
94 + sharedIdGroup.visibleMessages().at(-1).content === "updated response",
95 + "typed cache keys must still replace updates to the same message",
96 +);
97 +
98 +const logs = Array.from({{ length: 1000 }}, (_, no) => ({{
99 + no,
100 + type: no % 20 === 0 ? "user" : "tool",
101 + content: `log-${{no}}`,
102 +}}));
103 +const windowed = new MessageWindow({{ initialLimit: 60, pageSize: 60, maxWindow: 120 }});
104 +windowed.reset(logs);
105 +
106 +assert(windowed.start === 940 && windowed.end === 1000, "initial render must start at the tail");
107 +assert(windowed.visibleMessages()[0].no === 940, "tail slice must be ordered");
108 +assert(windowed.olderCount === 940 && windowed.newerCount === 0, "tail counts must be accurate");
109 +
110 +const unordered = new MessageWindow({{ initialLimit: 3, pageSize: 2, maxWindow: 4 }});
111 +unordered.reset([logs[2], logs[0], logs[1]]);
112 +assert(unordered.visibleMessages().map((entry) => entry.no).join(",") === "0,1,2", "out-of-order records must be sorted once");
113 +
114 +const groupedLogs = Array.from({{ length: 300 }}, (_, no) => ({{
115 + no,
116 + unit: no >= 135 && no < 195 ? "large-process-group" : `entry-${{no}}`,
117 +}}));
118 +const groupedWindow = new MessageWindow({{
119 + initialLimit: 60,
120 + pageSize: 60,
121 + maxWindow: 120,
122 + getUnitKeys: (messages) => messages.map((message) => message.unit),
123 +}});
124 +groupedWindow.reset(groupedLogs);
125 +groupedWindow.shiftOlder();
126 +assert(groupedWindow.visibleStart === 135 && groupedWindow.visibleEnd === 300, "a page boundary must expand to the complete process group");
127 +assert(groupedWindow.visibleMessages().filter((message) => message.unit === "large-process-group").length === 60, "a process group must never be split across the window");
128 +groupedWindow.shiftOlder();
129 +assert(groupedWindow.visibleStart === 120 && groupedWindow.visibleEnd === 240, "older paging must retain the whole intersecting group");
130 +groupedWindow.shiftNewer();
131 +assert(groupedWindow.visibleStart === 135 && groupedWindow.visibleEnd === 300, "newer paging must restore the whole-group tail range");
132 +
133 +windowed.shiftOlder();
134 +assert(windowed.start === 880 && windowed.end === 1000, "first older page should retain the tail overlap");
135 +windowed.shiftOlder();
136 +assert(windowed.start === 820 && windowed.end === 940, "older paging must retain exactly the adjacent page");
137 +assert(windowed.hasOlder && windowed.hasNewer, "a historical window must page in both directions");
138 +
139 +windowed.shiftNewer();
140 +assert(windowed.start === 880 && windowed.end === 1000, "newer paging must reverse the older-page swap exactly");
141 +assert(windowed.renderedCount === 120, "a shifted window must contain exactly two pages");
142 +
143 +windowed.showHead();
144 +assert(windowed.start === 0 && windowed.end === 60, "the initial head view must contain one page");
145 +windowed.shiftNewer();
146 +assert(windowed.start === 0 && windowed.end === 120, "the first forward shift must retain page A and append page B");
147 +windowed.shiftNewer();
148 +assert(windowed.start === 60 && windowed.end === 180, "the second forward shift must retain B and append C");
149 +windowed.shiftNewer();
150 +assert(windowed.start === 120 && windowed.end === 240, "the third forward shift must retain C and append D");
151 +windowed.shiftOlder();
152 +assert(windowed.start === 60 && windowed.end === 180, "a reverse shift must restore B beside C");
153 +windowed.shiftOlder();
154 +assert(windowed.start === 0 && windowed.end === 120, "a second reverse shift must restore A beside B");
155 +
156 +windowed.showTail();
157 +windowed.shiftOlder();
158 +assert(windowed.start === 880 && windowed.end === 1000, "tail paging must restore a two-page live window");
159 +
160 +windowed.merge(Array.from({{ length: 20 }}, (_, offset) => ({{
161 + no: 1000 + offset,
162 + type: "tool",
163 + content: `new-${{offset}}`,
164 +}})));
165 +assert(windowed.end === 1020, "live tail appends must remain visible");
166 +assert(windowed.compactTailIfNeeded(), "an oversized live window must compact");
167 +assert(windowed.start === 900 && windowed.end === 1020, "compaction must retain two complete tail pages");
168 +assert(windowed.renderedCount === 120, "tail compaction must use the same two-page bound");
169 +
170 +windowed.merge([{{ no: 1019, type: "response", content: "updated" }}]);
171 +const updated = windowed.visibleMessages().at(-1);
172 +assert(updated.type === "response" && updated.content === "updated", "existing log updates must replace cached data");
173 +
174 +windowed.reset(logs);
175 +windowed.merge([{{ no: 1000, type: "tool", content: "unread" }}], {{ followTail: false }});
176 +assert(windowed.end === 1000, "an unfollowed live append must not move the visible window");
177 +assert(windowed.newerCount === 1, "an unfollowed live append must remain available as a newer page");
178 +"""
179 + subprocess.run(
180 + ["node", "--input-type=module", "-e", script],
181 + check=True,
182 + text=True,
183 + )
184 +
185 +
186 +def test_collapsed_process_details_are_deferred_and_discarded():
187 + messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text(
188 + encoding="utf-8"
189 + )
190 + process_group_dom = (
191 + PROJECT_ROOT
192 + / "webui"
193 + / "components"
194 + / "messages"
195 + / "process-group"
196 + / "process-group-dom.js"
197 + ).read_text(encoding="utf-8")
198 +
199 + assert "detailPending: !shouldRenderDetail" in messages
200 + assert "discardProcessStepDetail(step)" in messages
201 + assert 'step.__renderDetail !== "function"' in messages
202 + assert "estimateKvpTextSize(kvps)" in messages
203 + assert "kvps: expanded ? kvps : null" in messages
204 + assert "await Promise.allSettled(pending)" in process_group_dom
205 + assert "step.__setExpanded(shouldExpandStep)" in process_group_dom
206 +
207 +
208 +def test_user_messages_share_collapse_behavior_without_clipping_attachments():
209 + messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text(
210 + encoding="utf-8"
211 + )
212 + message_css = (PROJECT_ROOT / "webui" / "css" / "messages.css").read_text(
213 + encoding="utf-8"
214 + )
215 + action_button_css = (
216 + PROJECT_ROOT
217 + / "webui"
218 + / "components"
219 + / "messages"
220 + / "action-buttons"
221 + / "simple-action-buttons.css"
222 + ).read_text(encoding="utf-8")
223 +
224 + assert 'contentSelector = ":scope > .message-body"' in messages
225 + assert 'collapseContent?.classList.add("message-collapse-content")' in messages
226 + assert '":scope > .message-text",\n );' in messages
227 + assert "attachmentsContainer.classList.add(\"attachments-container\")" in messages
228 + assert ".message.message-collapsible .message-collapse-content" in message_css
229 + assert ".message.message-agent-response.message-collapsible" in message_css
230 + assert ".attachments-container.message-collapse-content" not in message_css
231 + assert ".message-user .step-action-buttons .expand-btn" in action_button_css
232 + assert "order: 1" in action_button_css
233 +
234 +
235 +def test_process_groups_are_atomic_and_page_steps_in_fifties():
236 + messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text(
237 + encoding="utf-8"
238 + )
239 + group_css = (
240 + PROJECT_ROOT
241 + / "webui"
242 + / "components"
243 + / "messages"
244 + / "process-group"
245 + / "process-group.css"
246 + ).read_text(encoding="utf-8")
247 +
248 + assert "const PROCESS_GROUP_STEP_PAGE_SIZE = 50" in messages
249 + assert 'classifyMessageRenderUnits(messages)' in messages
250 + assert '"code_exe",' in MESSAGE_WINDOW_JS.read_text(encoding="utf-8")
251 + assert "getUnitKeys: getMessageRenderUnitKeys" in messages
252 + assert "getProcessGroupRenderMessages(windowMessages)" in messages
253 + assert 'button.className = "process-group-show-more"' in messages
254 + assert "current + PROCESS_GROUP_STEP_PAGE_SIZE" in messages
255 + assert "group.dataset.fullStartTimestamp" in messages
256 + assert "isUtilityOnlyProcessGroup(group)" in messages
257 + assert "allowCompletedGroup: false" in messages
258 + assert ".process-group.utility-only[hidden]" in group_css
259 + assert ".process-group-show-more" in group_css
260 +
261 +
262 +def test_detail_preferences_await_materialization_and_select_current_step():
263 + if not shutil.which("node"):
264 + pytest.skip("Node.js is required to execute the detail-mode regression.")
265 +
266 + source = PROCESS_GROUP_DOM_JS.read_text(encoding="utf-8").replace(
267 + 'import { store as preferencesStore } from '
268 + '"/components/sidebar/bottom/preferences/preferences-store.js";\n',
269 + 'const preferencesStore = { detailMode: "current", showUtils: false };\n',
270 + )
271 + module_url = "data:text/javascript;base64," + base64.b64encode(
272 + source.encode("utf-8")
273 + ).decode("ascii")
274 + script = f"""
275 +import {{ applyModeSteps }} from {module_url!r};
276 +
277 +function assert(condition, message) {{
278 + if (!condition) throw new Error(message);
279 +}}
280 +
281 +function makeClasses(initial = []) {{
282 + const values = new Set(initial);
283 + return {{
284 + contains: (name) => values.has(name),
285 + toggle(name, force) {{
286 + if (force) values.add(name);
287 + else values.delete(name);
288 + }},
289 + }};
290 +}}
291 +
292 +function makeStep(name, util = false) {{
293 + const step = {{
294 + name,
295 + classList: makeClasses(util ? ["message-util"] : []),
296 + detailReady: false,
297 + }};
298 + step.__setExpanded = (expanded) => new Promise((resolve) => {{
299 + queueMicrotask(() => {{
300 + step.classList.toggle("expanded", expanded);
301 + step.detailReady = expanded;
302 + resolve();
303 + }});
304 + }});
305 + return step;
306 +}}
307 +
308 +function makeGroup(steps, complete = false) {{
309 + const group = {{
310 + steps,
311 + complete,
312 + classList: makeClasses(),
313 + hasAttribute: (name) => complete && name === "data-group-complete",
314 + querySelector: (selector) =>
315 + complete && selector === ".process-group-response" ? {{}} : null,
316 + querySelectorAll: (selector) => selector === ".process-step" ? steps : [],
317 + }};
318 + group.__setExpanded = async (expanded) => {{
319 + group.classList.toggle("expanded", expanded);
320 + }};
321 + return group;
322 +}}
323 +
324 +const completedSteps = [makeStep("old")];
325 +const currentSteps = [makeStep("first"), makeStep("current"), makeStep("util", true)];
326 +const groups = [makeGroup(completedSteps, true), makeGroup(currentSteps)];
327 +const history = {{
328 + dataset: {{ messageWindowEnd: "100", messageWindowTotal: "100" }},
329 + querySelectorAll: (selector) => selector === ".process-group" ? groups : [],
330 +}};
331 +globalThis.document = {{
332 + getElementById: (id) => id === "chat-history" ? history : null,
333 +}};
334 +
335 +await applyModeSteps("expanded", false);
336 +assert(
337 + [...completedSteps, ...currentSteps].every((step) => step.detailReady),
338 + "ALL must await every visible step detail",
339 +);
340 +
341 +await applyModeSteps("collapsed", false);
342 +assert(
343 + [...completedSteps, ...currentSteps].every((step) => !step.detailReady),
344 + "NO must collapse every visible step",
345 +);
346 +
347 +await applyModeSteps("current", false);
348 +assert(!completedSteps[0].detailReady, "STEP must not open completed history");
349 +assert(!currentSteps[0].detailReady, "STEP must collapse older active steps");
350 +assert(currentSteps[1].detailReady, "STEP must materialize the current visible step");
351 +assert(!currentSteps[2].detailReady, "hidden utility steps must not become current");
352 +
353 +history.dataset.messageWindowEnd = "50";
354 +await applyModeSteps("current", false);
355 +assert(
356 + currentSteps.every((step) => !step.detailReady),
357 + "STEP must not treat a historical window boundary as the live current step",
358 +);
359 +"""
360 + subprocess.run(
361 + ["node", "--input-type=module", "-e", script],
362 + check=True,
363 + text=True,
364 + )
365 +
366 +
367 +def test_virtual_rebuild_cancels_stale_scroller_effects():
368 + if not shutil.which("node"):
369 + pytest.skip("Node.js is required to execute the scroller regression.")
370 +
371 + source = SCROLLER_JS.read_bytes()
372 + module_url = "data:text/javascript;base64," + base64.b64encode(source).decode(
373 + "ascii"
374 + )
375 + script = f"""
376 +import {{ cancelPendingScroll }} from {module_url!r};
377 +
378 +function assert(condition, message) {{
379 + if (!condition) throw new Error(message);
380 +}}
381 +
382 +let delayedScrollRan = false;
383 +const timeoutId = setTimeout(() => {{ delayedScrollRan = true; }}, 30);
384 +const element = {{
385 + dataset: {{
386 + scrollerTimeout: String(Number(timeoutId)),
387 + scrollerReapplySnapshot: "200",
388 + scrollingTo: "900",
389 + }},
390 + scrollTop: 240,
391 + scrollCalls: [],
392 + scrollTo(options) {{ this.scrollCalls.push(options); }},
393 +}};
394 +
395 +cancelPendingScroll(element);
396 +await new Promise((resolve) => setTimeout(resolve, 60));
397 +
398 +assert(!delayedScrollRan, "a stale delayed auto-scroll must be canceled");
399 +assert(element.scrollCalls.length === 1, "an in-flight smooth scroll must be stopped");
400 +assert(element.scrollCalls[0].top === 240, "canceling must retain the current offset");
401 +assert(!("scrollerTimeout" in element.dataset), "timeout state must be cleared");
402 +assert(!("scrollingTo" in element.dataset), "smooth-scroll state must be cleared");
403 +"""
404 + subprocess.run(
405 + ["node", "--input-type=module", "-e", script],
406 + check=True,
407 + text=True,
408 + )
409 +
410 +
411 +def test_virtual_window_preserves_live_and_navigation_contracts():
412 + messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text(
413 + encoding="utf-8"
414 + )
415 + message_css = (PROJECT_ROOT / "webui" / "css" / "messages.css").read_text(
416 + encoding="utf-8"
417 + )
418 + navigation = (
419 + PROJECT_ROOT
420 + / "webui"
421 + / "components"
422 + / "chat"
423 + / "navigation"
424 + / "chat-navigation-store.js"
425 + ).read_text(encoding="utf-8")
426 +
427 + assert "_messageRenderGeneration" in messages
428 + assert "result: { element: null, virtualized: true, dontScroll: true }" in messages
429 + assert 'scrollMessageWindowToEdge("start")' in navigation
430 + assert 'scrollMessageWindowToEdge("end")' in navigation
431 + assert 'loadAdjacentMessageWindow("older")' in navigation
432 + assert 'loadAdjacentMessageWindow("newer")' in navigation
433 + assert "_messageWindowFollowTail" in messages
434 + assert "hasUserScrollIntent" in messages
435 + assert "cancelPendingScroll(history)" in messages
436 + assert "createMessageWindowStagingHistory(history)" in messages
437 + assert "history.replaceChildren(...stagedChildren)" in messages
438 + assert 'element.classList.add("message-window-restored")' in messages
439 + assert "createMessageWindowIndicator" in messages
440 + assert 'document.createElement("div")' in messages
441 + assert "Loading ${label} messages" in messages
442 + assert "Load ${Math.min" not in messages
443 + assert "overflow-anchor: none" in message_css
444 + assert ".message-container.message-window-restored" in message_css
webui/components/chat/AGENTS.md
+1
@@ -27,6 +27,7 @@
27 - Model setup surfaces that change readiness must notify the gate with `model-setup-changed`, `model-configured`, or an existing modal/onboarding completion signal so the pending prompt can retry automatically.
28 - The top-section project selector, clock, and connection indicator must respect the instance-level mobile/desktop visibility preferences.
29 - While the selected context is running, an empty composer makes the primary button stop the active run; typed text still adds to the queue, and Enter with an empty composer still sends queued messages.
30 +- Chat navigation controls must cross virtual message-window boundaries; top and bottom target the full cached history rather than only the mounted DOM slice.
31
32 ## Work Guidance
33
webui/components/chat/navigation/chat-navigation-store.js
+46 -19
@@ -1,4 +1,9 @@
1 import { createStore } from "/js/AlpineStore.js";
2 +import {
3 + getMessageWindowState,
4 + loadAdjacentMessageWindow,
5 + scrollMessageWindowToEdge,
6 +} from "/js/messages.js";
7
8 const model = {
9 // Configuration
@@ -10,27 +15,21 @@ const model = {
15 // Any initialization if needed
16 },
17
13 - scrollToTop() {
14 - const scroller = this._getChatHistoryEl();
15 - if (scroller) scroller.scrollTo({ top: 0, behavior: "instant" });
18 + async scrollToTop() {
19 + await scrollMessageWindowToEdge("start");
20 },
21
18 - scrollToBottom() {
19 - if (globalThis.forceScrollChatToBottom) {
20 - globalThis.forceScrollChatToBottom();
21 - } else {
22 - const scroller = this._getChatHistoryEl();
23 - if (scroller) scroller.scrollTop = scroller.scrollHeight;
24 - }
22 + async scrollToBottom() {
23 + await scrollMessageWindowToEdge("end");
24 },
25
27 - scrollToPrevUserMessage() {
26 + async scrollToPrevUserMessage() {
27 const scroller = this._getChatHistoryEl();
28 if (!scroller) return;
29
30 const positions = this._getUserMessagePositions(scroller);
31 const scrollerRect = scroller.getBoundingClientRect();
33 -
32 +
33 const prevThreshold = this.scrollMargin - this.prevTolerance; // 25px
34
35 const currentIndex = positions.findIndex((p) => {
@@ -42,21 +41,28 @@ const model = {
41 // Go to previous message
42 positions[currentIndex - 1].el.scrollIntoView({ block: "start", behavior: "smooth" });
43 } else if (currentIndex === 0) {
45 - // At the first message, scroll to top
46 - scroller.scrollTo({ top: 0, behavior: "instant" });
44 + if (getMessageWindowState().hasOlder) {
45 + await loadAdjacentMessageWindow("older");
46 + this._scrollToLastUserAboveThreshold(scroller, prevThreshold);
47 + } else {
48 + scroller.scrollTo({ top: 0, behavior: "instant" });
49 + }
50 } else if (currentIndex === -1 && positions.length > 0) {
51 // All messages are above the threshold (scrolled past), scroll to bottom
52 positions[positions.length - 1].el.scrollIntoView({ block: "start", behavior: "smooth" });
53 + } else if (positions.length === 0 && getMessageWindowState().hasOlder) {
54 + await loadAdjacentMessageWindow("older");
55 + this._scrollToLastUserAboveThreshold(scroller, prevThreshold);
56 }
57 },
58
53 - scrollToNextUserMessage() {
59 + async scrollToNextUserMessage() {
60 const scroller = this._getChatHistoryEl();
61 if (!scroller) return;
62
63 const positions = this._getUserMessagePositions(scroller);
64 const scrollerRect = scroller.getBoundingClientRect();
59 -
65 +
66 const nextThreshold = this.scrollMargin + this.nextTolerance; // 65px
67
68 // Find first message below the threshold
@@ -69,16 +75,37 @@ const model = {
75 // Go to that message
76 positions[targetIndex].el.scrollIntoView({ block: "start", behavior: "smooth" });
77 } else {
72 - // No message found below threshold => scroll to bottom
73 - this.scrollToBottom();
78 + if (getMessageWindowState().hasNewer) {
79 + await loadAdjacentMessageWindow("newer");
80 + this._scrollToFirstUserBelowThreshold(scroller, nextThreshold);
81 + } else {
82 + await this.scrollToBottom();
83 + }
84 }
85 },
86
87 + _scrollToLastUserAboveThreshold(scroller, threshold) {
88 + const scrollerRect = scroller.getBoundingClientRect();
89 + const positions = this._getUserMessagePositions(scroller);
90 + const candidates = positions.filter(
91 + ({ el }) => el.getBoundingClientRect().top - scrollerRect.top < threshold
92 + );
93 + candidates.at(-1)?.el.scrollIntoView({ block: "start", behavior: "smooth" });
94 + },
95 +
96 + _scrollToFirstUserBelowThreshold(scroller, threshold) {
97 + const scrollerRect = scroller.getBoundingClientRect();
98 + const target = this._getUserMessagePositions(scroller).find(
99 + ({ el }) => el.getBoundingClientRect().top - scrollerRect.top > threshold
100 + );
101 + target?.el.scrollIntoView({ block: "start", behavior: "smooth" });
102 + },
103 +
104 // Helpers
105 _getChatHistoryEl() {
106 return document.getElementById("chat-history");
107 },
81 -
108 +
109 _getUserMessagePositions(scroller) {
110 const userMessageEls = Array.from(
111 scroller.querySelectorAll(".message-container.user-container")
webui/components/messages/AGENTS.md
+6
@@ -16,6 +16,12 @@
16 - Sanitize or safely render model/user-provided content through shared rendering paths.
17 - Avoid layout shifts that break long-running message streaming.
18 - Keep message action chrome out of text selection so copy/paste captures message content without button labels or icons.
19 +- Order standard message actions as Detail, Copy, then Speak; omit unavailable actions without changing the relative order of the remaining controls. Plugin-rendered message actions must follow the same order.
20 +- Keep collapsed process-step detail text out of the DOM; opening a step may materialize its current cached log data and collapsing it must discard that heavy detail again without removing extension action hooks.
21 +- Preference-driven process detail modes must await the same materialization path as manual expansion and accept an explicit chat-history target for off-screen window staging. `STEP` opens only the current non-utility step at the live tail; historical windows must not invent a current step at their boundary.
22 +- Keep oversized standalone replay bodies and key/value tables in a bounded preview state until the user expands them; collapsing must remove the full body again.
23 +- Message-window boundaries must not split process groups. Groups with more than 50 steps initially render their newest 50 steps and prepend earlier steps in 50-step increments through the group-local `Show more` control while retaining stable full-group header metrics.
24 +- A root response may attach only to a group containing a non-utility process step. Utility-only groups remain separate and hidden while utility messages are disabled, and completed groups must not absorb later utility records.
25
26 ## Work Guidance
27
webui/components/messages/action-buttons/simple-action-buttons.css
+4
@@ -127,6 +127,10 @@
127 width: 100%;
128 }
129
130 +.message-user .step-action-buttons .expand-btn {
131 + order: 1;
132 +}
133 +
134
135
136 /* ===========================================
webui/components/messages/process-group/process-group-dom.js
+54 -7
@@ -3,27 +3,74 @@
3 */
4 import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
5
6 -export function applyModeSteps(detailMode, showUtils) {
6 +export async function applyModeSteps(
7 + detailMode,
8 + showUtils,
9 + chatHistory = document.getElementById("chat-history"),
10 +) {
11 const mode =
12 detailMode ||
13 preferencesStore.detailMode ||
14 "current";
15
12 - const chatHistory = document.getElementById("chat-history");
16 if (!chatHistory) return;
17
18 chatHistory.dataset.detailMode = mode;
19
20 const shouldExpand = mode !== "collapsed";
21 const allMode = mode === "expanded";
19 - const messages = chatHistory.querySelectorAll(".process-group");
22 + const showUtilsFlag =
23 + typeof showUtils === "boolean"
24 + ? showUtils
25 + : preferencesStore.showUtils || false;
26 + const pending = [];
27 + const messages = Array.from(chatHistory.querySelectorAll(".process-group"));
28 + const windowEnd = Number(chatHistory.dataset.messageWindowEnd);
29 + const windowTotal = Number(chatHistory.dataset.messageWindowTotal);
30 + const isAtTail =
31 + Number.isFinite(windowEnd) &&
32 + Number.isFinite(windowTotal) &&
33 + windowEnd >= windowTotal;
34 for (let i = 0; i < messages.length; i += 1) {
21 - messages[i].classList.toggle("expanded", shouldExpand);
35 + const group = messages[i];
36 + if (typeof group.__setExpanded === "function") {
37 + pending.push(Promise.resolve(group.__setExpanded(shouldExpand)));
38 + } else {
39 + group.classList.toggle("expanded", shouldExpand);
40 + }
41 +
42 + const steps = Array.from(group.querySelectorAll(".process-step"));
43 + const isComplete =
44 + group.hasAttribute("data-group-complete") ||
45 + Boolean(group.querySelector(".process-group-response"));
46 + let currentStep = null;
47 + if (
48 + mode === "current" &&
49 + isAtTail &&
50 + group === messages[messages.length - 1] &&
51 + !isComplete
52 + ) {
53 + for (let si = steps.length - 1; si >= 0; si -= 1) {
54 + if (
55 + showUtilsFlag ||
56 + !steps[si].classList.contains("message-util")
57 + ) {
58 + currentStep = steps[si];
59 + break;
60 + }
61 + }
62 + }
63
23 - const steps = messages[i].querySelectorAll(".process-step");
64 for (let si = 0; si < steps.length; si += 1) {
25 - steps[si].classList.toggle("expanded", allMode);
65 + const step = steps[si];
66 + const shouldExpandStep = allMode || step === currentStep;
67 + if (typeof step.__setExpanded === "function") {
68 + pending.push(Promise.resolve(step.__setExpanded(shouldExpandStep)));
69 + } else {
70 + step.classList.toggle("expanded", shouldExpandStep);
71 + }
72 }
73 }
28 -}
74
75 + await Promise.allSettled(pending);
76 +}
webui/components/messages/process-group/process-group.css
+23 -2
@@ -11,6 +11,10 @@
11 flex-shrink: 0;
12 }
13
14 +.process-group.utility-only[hidden] {
15 + display: none !important;
16 +}
17 +
18 /* Embedded Process Group inside Response */
19 .process-group.embedded {
20 display: flex;
@@ -377,6 +381,25 @@
381 overflow: hidden;
382 }
383
384 +.process-group-show-more {
385 + align-self: flex-start;
386 + appearance: none;
387 + border: 0;
388 + background: transparent;
389 + color: var(--color-text-muted);
390 + cursor: pointer;
391 + font-family: var(--font-family-main);
392 + font-size: var(--font-size-xs);
393 + padding: var(--spacing-xs) 0;
394 + text-decoration: underline;
395 + text-underline-offset: 0.15em;
396 +}
397 +
398 +.process-group-show-more:hover,
399 +.process-group-show-more:focus-visible {
400 + color: var(--color-text);
401 +}
402 +
403 /* Individual Process Step */
404 .process-step {
405 display: flex;
@@ -874,5 +897,3 @@
897 .process-step:not(.expanded) > .process-step-detail > .step-detail-actions {
898 display: none;
899 }
877 -
878 -
webui/components/sidebar/AGENTS.md
+4
@@ -23,9 +23,13 @@
23 - Running parent and child chats share the chat-list working-bubble animation; keep it scoped away from task and connection-status indicators.
24 - Chat and task lists reclaim the same part of the sidebar's left content inset so their project bubbles align, while their section headers retain the standard sidebar inset.
25 - Chat-row action buttons consume layout width only while a pointer row is hovered or while that row is selected on a touch device.
26 +- Built-in chat and task overflow menus follow the standard row actions; plugin controls remain direct row actions.
27 +- `sidebar-row-actions-menu` owns plugin-contributed row-menu actions; list-order plugins register stable sort and divider callbacks through the sidebar store instead of patching chat/task stores or injecting row DOM.
28 - Bottom version information shows its commit timestamp in UTC without a timezone suffix and remains on one line.
29 - Avoid text or controls overflowing fixed sidebar widths.
30 - Instance-level interface visibility preferences own independent mobile and desktop states for the chat-top controls and right canvas rail; mobile uses the shared 768px breakpoint.
31 +- Process-detail preference changes must use the message renderer's async expansion hooks and honor an explicit chat-history render target so staged pages are ready before an atomic swap.
32 +- The utility-message preference controls both individual utility steps and utility-only process-group chrome so hidden utility runs cannot leave empty headers in the transcript.
33
34 ## Work Guidance
35
webui/components/sidebar/bottom/preferences/preferences-store.js
+10 -1
@@ -209,6 +209,9 @@ const model = {
209 "display",
210 value ? undefined : "none"
211 );
212 + document.querySelectorAll(".process-group.utility-only").forEach((group) => {
213 + group.hidden = !value;
214 + });
215 },
216
217 _applyChatWidth(value) {
@@ -222,10 +225,16 @@ const model = {
225 }
226 },
227
228 + applyCurrentDetailMode(chatHistory = undefined) {
229 + return applyModeSteps(this._detailMode, this._showUtils, chatHistory);
230 + },
231 +
232 _applyDetailMode(value) {
233 localStorage.setItem("detailMode", value);
234 // Apply mode to all existing DOM elements
228 - applyModeSteps(this._detailMode, this._showUtils);
235 + void this.applyCurrentDetailMode().catch((error) => {
236 + console.error("Failed to apply process detail mode", error);
237 + });
238 },
239 };
240
webui/components/sidebar/chats/chats-list.html
+21 -4
@@ -23,8 +23,9 @@
23
24
25 <ul class="config-list chats-config-list no-scrollbar" x-show="$store.chats.topLevelContexts().length > 0">
26 - <template x-for="context in $store.chats.topLevelContexts()" :key="context.id">
27 - <li class="chat-tree-item">
26 + <template x-for="(context, contextIndex) in $store.chats.topLevelContexts()" :key="context.id">
27 + <li class="chat-tree-item"
28 + :class="{ 'sidebar-list-divider': $store.sidebar.hasRowDividerBefore('chat', context, contextIndex, $store.chats.topLevelContexts()) }">
29 <div :class="{'chat-container': true, 'chat-has-children': $store.chats.hasChildren(context.id), 'chat-selected': context.id === $store.chats.selected}"
30 @click="$store.chats.selectChat(context.id)">
31 <button class="chat-expand-btn"
@@ -45,10 +46,16 @@
46 <button class="btn-icon-action chat-list-action-btn" title="Close chat" @click.stop="$confirmClick($event, () => $store.chats.killChat(context.id))">
47 <span class="material-symbols-outlined">close</span>
48 </button>
49 + <button type="button" class="btn-icon-action chat-list-action-btn" title="More chat actions"
50 + aria-label="More chat actions" aria-haspopup="menu"
51 + :aria-expanded="($store.sidebar.rowMenuOpenId === `chat:${context.id}`).toString()"
52 + @click.stop="$store.sidebar.rowMenuToggle(`chat:${context.id}`, 'chat', $event.currentTarget)">
53 + <span class="material-symbols-outlined">more_vert</span>
54 + </button>
55 </div>
56 <ul class="chat-child-list" x-show="$store.chats.isExpanded(context.id) && $store.chats.hasChildren(context.id)">
50 - <template x-for="child in $store.chats.childContexts(context.id)" :key="child.id">
51 - <li>
57 + <template x-for="(child, childIndex) in $store.chats.childContexts(context.id)" :key="child.id">
58 + <li :class="{ 'sidebar-list-divider': $store.sidebar.hasRowDividerBefore('chat', child, childIndex, $store.chats.childContexts(context.id)) }">
59 <div :class="{'chat-container': true, 'chat-child-container': true, 'chat-selected': child.id === $store.chats.selected}"
60 @click="$store.chats.selectChat(child.id)">
61 <div class="chat-child-indent"></div>
@@ -61,6 +68,12 @@
68 <button class="btn-icon-action chat-list-action-btn" title="Close chat" @click.stop="$confirmClick($event, () => $store.chats.killChat(child.id))">
69 <span class="material-symbols-outlined">close</span>
70 </button>
71 + <button type="button" class="btn-icon-action chat-list-action-btn" title="More chat actions"
72 + aria-label="More chat actions" aria-haspopup="menu"
73 + :aria-expanded="($store.sidebar.rowMenuOpenId === `chat:${child.id}`).toString()"
74 + @click.stop="$store.sidebar.rowMenuToggle(`chat:${child.id}`, 'chat', $event.currentTarget)">
75 + <span class="material-symbols-outlined">more_vert</span>
76 + </button>
77 </div>
78 </li>
79 </template>
@@ -109,6 +122,10 @@
122 max-height: 100%;
123 }
124
125 + .chats-config-list > .chat-tree-item {
126 + padding-block: 0.14rem;
127 + }
128 +
129 .chat-container {
130 position: relative;
131 display: flex;
webui/components/sidebar/chats/chats-store.js
+9 -2
@@ -10,6 +10,7 @@ import {
10 getConnectionStatus,
11 } from "/index.js";
12 import { store as notificationStore } from "/components/notifications/notification-store.js";
13 +import { store as sidebarStore } from "/components/sidebar/sidebar-store.js";
14 import { store as tasksStore } from "/components/sidebar/tasks/tasks-store.js";
15 import { store as syncStore } from "/components/sync/sync-store.js";
16 import { store as chatInputStore } from "/components/chat/input/input-store.js";
@@ -80,11 +81,17 @@ const model = {
81 },
82
83 topLevelContexts() {
83 - return this.contexts.filter((ctx) => !ctx?.parent_context_id);
84 + return sidebarStore.sortRows(
85 + "chat",
86 + this.contexts.filter((ctx) => !ctx?.parent_context_id),
87 + );
88 },
89
90 childContexts(parentId) {
87 - return this.contexts.filter((ctx) => ctx?.parent_context_id === parentId);
91 + return sidebarStore.sortRows(
92 + "chat",
93 + this.contexts.filter((ctx) => ctx?.parent_context_id === parentId),
94 + );
95 },
96
97 hasChildren(parentId) {
webui/components/sidebar/left-sidebar.html
+20 -1
@@ -10,7 +10,9 @@
10 <body>
11 <div x-data>
12 <template x-if="$store.sidebar">
13 - <div class="sidebar-shell">
13 + <div class="sidebar-shell"
14 + @click.window="$store.sidebar.rowMenuClick($event, $refs.rowActionsMenu)"
15 + @keydown.escape.window="$store.sidebar.rowMenuClose()">
16 <x-component path="sidebar/top-section/header-icons.html"></x-component>
17 <div id="left-panel" class="panel" x-data :class="{'hidden': !$store.sidebar.isOpen}">
18 <x-extension id="sidebar-start"></x-extension>
@@ -34,6 +36,11 @@
36 </div>
37 <x-extension id="sidebar-end"></x-extension>
38 </div>
39 + <div x-ref="rowActionsMenu" class="dropdown-menu sidebar-row-actions-menu" role="menu"
40 + x-show="$store.sidebar.rowMenuOpenId" x-transition :style="$store.sidebar.rowMenuStyle"
41 + style="display: none;" @click.stop>
42 + <x-extension id="sidebar-row-actions-menu"></x-extension>
43 + </div>
44 </div>
45 </template>
46 </div>
@@ -99,6 +106,18 @@
106 /* Prevent bottom from growing */
107 }
108
109 + .sidebar-row-actions-menu {
110 + position: fixed;
111 + z-index: 9999;
112 + margin: 0;
113 + }
114 +
115 + .config-list li.sidebar-list-divider {
116 + border-top: 1px solid color-mix(in srgb, var(--color-border) 55%, transparent);
117 + margin-top: var(--spacing-xs);
118 + padding-top: calc(0.28rem + var(--spacing-xs));
119 + }
120 +
121 /* Chats section container inside sidebar */
122 #chats-section {
123 display: -webkit-flex;
webui/components/sidebar/sidebar-store.js
+69
@@ -4,6 +4,10 @@ import { createStore } from "/js/AlpineStore.js";
4 const model = {
5 isOpen: true,
6 menuOpen: false,
7 + rowMenuOpenId: "",
8 + rowMenuKind: "chat",
9 + rowMenuStyle: {},
10 + rowListExtensions: { chat: {}, task: {} },
11 _initialized: false,
12
13 // Centralized collapse state for all sidebar sections (persisted in localStorage)
@@ -85,6 +89,7 @@ const model = {
89 this.isOpen = false;
90 }
91 this.menuClose();
92 + this.rowMenuClose();
93 },
94
95 // Check if the current viewport is mobile
@@ -132,6 +137,70 @@ const model = {
137 width: `${menuWidth}px`
138 };
139 },
140 +
141 + registerRowListExtension(kind, name, extension) {
142 + if (!this.rowListExtensions[kind] || !name) return;
143 + this.rowListExtensions = {
144 + ...this.rowListExtensions,
145 + [kind]: { ...this.rowListExtensions[kind], [name]: extension },
146 + };
147 + },
148 +
149 + sortRows(kind, rows) {
150 + return Object.values(this.rowListExtensions[kind] || {}).reduce(
151 + (result, extension) => extension.sort?.(result) || result,
152 + [...rows],
153 + );
154 + },
155 +
156 + hasRowDividerBefore(kind, item, index, rows) {
157 + return Object.values(this.rowListExtensions[kind] || {}).some((extension) =>
158 + extension.dividerBefore?.(item, index, rows),
159 + );
160 + },
161 +
162 + rowMenuToggle(id, kind, triggerElement) {
163 + if (this.rowMenuOpenId === id) {
164 + this.rowMenuClose();
165 + return;
166 + }
167 +
168 + this.rowMenuOpenId = id;
169 + this.rowMenuKind = kind;
170 + this.rowMenuStyle = this.rowMenuPos(triggerElement);
171 + },
172 +
173 + rowMenuClose() {
174 + this.rowMenuOpenId = "";
175 + this.rowMenuStyle = {};
176 + },
177 +
178 + rowMenuClick(event, menuElement) {
179 + if (!this.rowMenuOpenId || menuElement?.contains(event.target)) return;
180 + this.rowMenuClose();
181 + },
182 +
183 + rowMenuPos(triggerElement) {
184 + if (!triggerElement) return {};
185 +
186 + const rect = triggerElement.getBoundingClientRect();
187 + const gap = 6;
188 + const padding = 8;
189 + const menuWidth = 180;
190 + const spaceBelow = window.innerHeight - rect.bottom - gap - padding;
191 + const spaceAbove = rect.top - gap - padding;
192 + const openUp = spaceBelow < 96 && spaceAbove > spaceBelow;
193 + const maxLeft = Math.max(padding, window.innerWidth - menuWidth - padding);
194 + const left = Math.min(Math.max(rect.right - menuWidth, padding), maxLeft);
195 +
196 + return {
197 + left: `${Math.round(left)}px`,
198 + right: "auto",
199 + top: openUp ? "auto" : `${Math.round(rect.bottom + gap)}px`,
200 + bottom: openUp ? `${Math.round(window.innerHeight - rect.top + gap)}px` : "auto",
201 + minWidth: `${menuWidth}px`,
202 + };
203 + },
204 };
205
206 export const store = createStore("sidebar", model);
webui/components/sidebar/tasks/tasks-list.html
+12 -5
@@ -22,9 +22,10 @@
22 x-effect="(() => { const c = bootstrap.Collapse.getOrCreateInstance($el, { toggle: false }); $store.sidebar.isSectionOpen('tasks') ? c.show() : c.hide(); })()">
23 <div class="tasks-list-body">
24 <div class="tasks-list-container" x-data>
25 - <ul class="config-list tasks-config-list no-scrollbar" x-show="$store.tasks.tasks.length > 0">
26 - <template x-for="task in $store.tasks.tasks" :key="task.id">
27 - <li>
25 + <ul class="config-list tasks-config-list no-scrollbar" x-show="$store.tasks.visibleTasks().length > 0">
26 + <template x-for="(task, taskIndex) in $store.tasks.visibleTasks()" :key="task.id">
27 + <li class="task-list-item"
28 + :class="{ 'sidebar-list-divider': $store.sidebar.hasRowDividerBefore('task', task, taskIndex, $store.tasks.visibleTasks()) }">
29 <div class="task-container" :class="{'task-selected': task.id === $store.tasks.selected}">
30 <div class="chat-list-button" @click="$store.tasks.selectTask(task.id)">
31 <div class="task-container-vertical">
@@ -51,6 +52,12 @@
52 <button class="btn-icon-action" title="Delete task" @click.stop="$confirmClick($event, () => $store.tasks.deleteTask(task.id))">
53 <span class="material-symbols-outlined">close</span>
54 </button>
55 + <button type="button" class="btn-icon-action" title="More task actions"
56 + aria-label="More task actions" aria-haspopup="menu"
57 + :aria-expanded="($store.sidebar.rowMenuOpenId === `task:${task.id}`).toString()"
58 + @click.stop="$store.sidebar.rowMenuToggle(`task:${task.id}`, 'task', $event.currentTarget)">
59 + <span class="material-symbols-outlined">more_vert</span>
60 + </button>
61 </div>
62 </div>
63 </div>
@@ -59,7 +66,7 @@
66 </li>
67 </template>
68 </ul>
62 - <div class="empty-list-message" x-show="$store.tasks.tasks.length === 0">
69 + <div class="empty-list-message" x-show="$store.tasks.visibleTasks().length === 0">
70 <p><i>No tasks to list.</i></p>
71 </div>
72 </div>
@@ -101,7 +108,7 @@
108 overflow: hidden;
109 position: relative;
110 }
104 -
111 +
112 /* Scrollable tasks config list */
113 .tasks-config-list {
114 flex: 1;
webui/components/sidebar/tasks/tasks-store.js
+5 -1
@@ -1,5 +1,6 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
3 +import { store as sidebarStore } from "/components/sidebar/sidebar-store.js";
4 import { store as schedulerStore } from "/components/modals/scheduler/scheduler-store.js";
5
6 // Tasks sidebar store: tasks list and selected task id
@@ -39,6 +40,10 @@ const model = {
40 return Array.isArray(this.tasks) && this.tasks.some((t) => t?.id === taskId);
41 },
42
43 + visibleTasks() {
44 + return sidebarStore.sortRows("task", this.tasks);
45 + },
46 +
47 // Convenience: id of the first task in the current list (or empty string)
48 firstId() {
49 return (Array.isArray(this.tasks) && this.tasks[0]?.id) || "";
@@ -70,4 +75,3 @@ const model = {
75
76 export const store = createStore("tasks", model);
77
73 -
webui/css/AGENTS.md
+3
@@ -8,6 +8,7 @@
8 ## Ownership
9
10 - Each CSS file owns a named surface or primitive family such as buttons, messages, modals, notifications, scheduler, settings, surfaces, tables, or toast.
11 +- `messages.css` owns shared chat-history paging indicators and lazy message-preview states in addition to message presentation.
12 - Component-specific styles should usually stay inside the component HTML unless they are intentionally shared.
13 - `modals.css` owns the shared stacked modal shell, backdrop, scroll area, footer slot, modal button classes, floating/no-backdrop modal behavior, and shared modal section primitives.
14 - `surfaces.css` owns surface modal switchers, action rails, draggable header affordances, focus-button state, and right-canvas surface primitives.
@@ -27,6 +28,8 @@
28 - `.modal-floating` must keep the full-screen shell pointer-transparent while `.modal-inner` remains pointer-active.
29 - Use `.modal-no-backdrop` only for backdrop suppression without click-through floating behavior.
30 - Shared modal layers must stay above the mobile right-canvas rail while confirmation dialogs remain above normal modals.
31 +- Shared message collapsing targets `.message-collapse-content`; user-message attachments must remain outside that target so expanding text never changes attachment visibility.
32 +- The virtualized chat history disables native scroll anchoring and replay fade-in motion; the message-window renderer owns anchor restoration during atomic page swaps.
33 - Do not add decorative one-note palette changes that conflict with existing WebUI design.
34
35 ## Work Guidance
webui/css/messages.css
+125 -8
@@ -9,6 +9,7 @@
9 width: 100%;
10 overflow-y: scroll;
11 overflow-x: hidden;
12 + overflow-anchor: none;
13 scroll-behavior: auto !important; /* avoid infinite scrolling! */
14 padding-left: var(--spacing-sm) !important;
15 padding-right: var(--spacing-md) !important;
@@ -18,6 +19,91 @@
19 transition: all 0.3s ease;
20 }
21
22 +#chat-history.message-window-staging,
23 +#chat-history.message-window-staging * {
24 + animation: none !important;
25 + transition: none !important;
26 +}
27 +
28 +.message-window-loader {
29 + align-self: center;
30 + flex: 0 0 auto;
31 + display: flex;
32 + justify-content: center;
33 + margin: var(--spacing-sm) auto;
34 + min-height: 2rem;
35 + pointer-events: none;
36 +}
37 +
38 +.message-window-loader-bubble {
39 + display: inline-flex;
40 + align-items: center;
41 + gap: 0.28rem;
42 + padding: 0.55rem 0.75rem;
43 + border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
44 + border-radius: 999px;
45 + background: color-mix(in srgb, var(--color-panel) 92%, transparent);
46 + box-shadow: 0 0.2rem 0.7rem rgba(0, 0, 0, 0.14);
47 + opacity: 0.58;
48 + transition: opacity var(--transition-speed) ease;
49 +}
50 +
51 +.message-window-loader.is-loading .message-window-loader-bubble {
52 + opacity: 1;
53 +}
54 +
55 +.message-window-loader-bubble > span {
56 + width: 0.34rem;
57 + height: 0.34rem;
58 + border-radius: 50%;
59 + background: var(--color-text-muted);
60 + opacity: 0.55;
61 +}
62 +
63 +.message-window-loader.is-loading .message-window-loader-bubble > span {
64 + animation: message-window-loader-dot 1.05s ease-in-out infinite;
65 +}
66 +
67 +.message-window-loader-bubble > span:nth-child(2) {
68 + animation-delay: 0.14s;
69 +}
70 +
71 +.message-window-loader-bubble > span:nth-child(3) {
72 + animation-delay: 0.28s;
73 +}
74 +
75 +.message-window-loader-label {
76 + position: absolute;
77 + width: 1px;
78 + height: 1px;
79 + padding: 0;
80 + margin: -1px;
81 + overflow: hidden;
82 + clip: rect(0, 0, 0, 0);
83 + white-space: nowrap;
84 + border: 0;
85 +}
86 +
87 +@keyframes message-window-loader-dot {
88 + 0%,
89 + 60%,
90 + 100% {
91 + opacity: 0.35;
92 + transform: translateY(0);
93 + }
94 + 30% {
95 + opacity: 1;
96 + transform: translateY(-0.2rem);
97 + }
98 +}
99 +
100 +@media (prefers-reduced-motion: reduce) {
101 + .message-window-loader-bubble > span {
102 + animation: none;
103 + opacity: 0.7;
104 + }
105 +}
106 +
107 /* Message Styles */
108
109 .user-container {
@@ -818,6 +904,11 @@
904 -webkit-animation: none !important;
905 }
906
907 +.message-container.message-window-restored {
908 + animation: none !important;
909 + -webkit-animation: none !important;
910 +}
911 +
912 .message {
913 /* background-color: var(--color-message-bg); */
914 /* border-radius: 0; */
@@ -842,14 +933,14 @@
933 Shows ~10 lines with fade-out, expand button reveals full content
934 =========================================== */
935
845 -.message.message-collapsible .message-body {
936 +.message.message-collapsible .message-collapse-content {
937 position: relative;
938 max-height: 15em !important;
939 overflow: hidden;
940 transition: max-height 0.3s ease-out;
941 }
942
852 -.message.message-collapsible .message-body::after {
943 +.message.message-collapsible .message-collapse-content::after {
944 content: "";
945 position: absolute;
946 bottom: 0;
@@ -863,32 +954,58 @@
954 }
955
956 /* Expanded state */
866 -.message.message-collapsible.expanded .message-body {
957 +.message.message-collapsible.expanded .message-collapse-content {
958 max-height: none !important;
959 }
960
870 -.message.message-collapsible.expanded .message-body::after {
961 +.message.message-collapsible.expanded .message-collapse-content::after {
962 opacity: 0;
963 }
964
965 /* No fade when content fits */
875 -.message.message-collapsible:not(.has-overflow) .message-body::after {
966 +.message.message-collapsible:not(.has-overflow) .message-collapse-content::after {
967 opacity: 0;
968 }
969
970 /* ALL detail mode: force full response bodies and hide the expand control */
880 -#chat-history[data-detail-mode="expanded"] .message.message-collapsible .message-body {
971 +#chat-history[data-detail-mode="expanded"]
972 + .message.message-agent-response.message-collapsible
973 + .message-collapse-content {
974 max-height: none !important;
975 }
976
884 -#chat-history[data-detail-mode="expanded"] .message.message-collapsible .message-body::after {
977 +#chat-history[data-detail-mode="expanded"]
978 + .message.message-agent-response.message-collapsible
979 + .message-collapse-content::after {
980 opacity: 0;
981 }
982
888 -#chat-history[data-detail-mode="expanded"] .message.message-collapsible .expand-btn {
983 +#chat-history[data-detail-mode="expanded"]
984 + .message.message-agent-response.message-collapsible
985 + .expand-btn {
986 display: none !important;
987 }
988
989 +/* Long history replays keep oversized bodies as previews until explicitly opened,
990 + even when the global preference is ALL. */
991 +#chat-history[data-detail-mode="expanded"]
992 + .message.message-collapsible.lazy-content:not(.expanded)
993 + .message-collapse-content {
994 + max-height: 15em !important;
995 +}
996 +
997 +#chat-history[data-detail-mode="expanded"]
998 + .message.message-collapsible.lazy-content:not(.expanded)
999 + .message-collapse-content::after {
1000 + opacity: 1;
1001 +}
1002 +
1003 +#chat-history[data-detail-mode="expanded"]
1004 + .message.message-collapsible.lazy-content
1005 + .expand-btn {
1006 + display: inline-flex !important;
1007 +}
1008 +
1009 /* Smooth Render */
1010 .smooth-render-visible,
1011 .smooth-render-invisible {
webui/index.js
+5 -16
@@ -25,7 +25,6 @@ let leftPanel,
25 rightPanel,
26 container,
27 chatInput,
28 - chatHistory,
28 sendButton,
29 inputSection,
30 statusSection,
@@ -163,14 +162,8 @@ export async function sendMessage(options = {}) {
162 }
163 globalThis.sendMessage = sendMessage;
164
166 -function getChatHistoryEl() {
167 - return document.getElementById("chat-history");
168 -}
169 -
165 function forceScrollChatToBottom() {
171 - const chatHistoryEl = getChatHistoryEl();
172 - if (!chatHistoryEl) return;
173 - chatHistoryEl.scrollTop = chatHistoryEl.scrollHeight;
166 + return msgs.scrollMessageWindowToEdge("end");
167 }
168 globalThis.forceScrollChatToBottom = forceScrollChatToBottom;
169
@@ -357,8 +350,7 @@ export async function applySnapshot(snapshot, options = {}) {
350 // so the mismatch is expected and should not trigger a second state_request/poll.
351 if (lastLogGuid != snapshot.log_guid) {
352 if (lastLogGuid) {
360 - const chatHistoryEl = document.getElementById("chat-history");
361 - if (chatHistoryEl) chatHistoryEl.innerHTML = "";
353 + msgs.resetMessageRenderState();
354 lastLogVersion = 0;
355 lastLogGuid = snapshot.log_guid;
356 if (typeof onLogGuidReset === "function") {
@@ -374,8 +366,7 @@ export async function applySnapshot(snapshot, options = {}) {
366 if (lastLogVersion != snapshot.log_version) {
367 updated = true;
368 if (snapshot.logs?.[0]?.no === 0) {
377 - const chatHistoryEl = document.getElementById("chat-history");
378 - if (chatHistoryEl) chatHistoryEl.innerHTML = "";
369 + msgs.resetMessageRenderState();
370 }
371 await setMessages(modelGateStore.mergeSyntheticMessages(snapshot.logs, context));
372 afterMessagesUpdate(snapshot.logs);
@@ -579,8 +570,7 @@ export const setContext = function (id) {
570 ttsService.stop();
571
572 // Clear the chat history immediately to avoid showing stale content
582 - const chatHistoryEl = document.getElementById("chat-history");
583 - if (chatHistoryEl) chatHistoryEl.innerHTML = "";
573 + msgs.resetMessageRenderState();
574
575 // Update both selected states using stores
576 chatsStore.setSelected(id);
@@ -618,7 +608,7 @@ export const deselectChat = function () {
608 sessionStorage.removeItem("lastSelectedTask");
609
610 // Clear the chat history
621 - if (chatHistory) chatHistory.innerHTML = "";
611 + msgs.resetMessageRenderState();
612 };
613 globalThis.deselectChat = deselectChat;
614
@@ -793,7 +783,6 @@ document.addEventListener("DOMContentLoaded", function () {
783 rightPanel = document.getElementById("right-panel");
784 container = document.querySelector(".container");
785 chatInput = document.getElementById("chat-input");
796 - chatHistory = document.getElementById("chat-history");
786 sendButton = document.getElementById("send-button");
787 inputSection = document.getElementById("input-section");
788 statusSection = document.getElementById("status-section");
webui/js/AGENTS.md
+8
@@ -16,6 +16,8 @@
16 - `surfaces.js` owns shared surface registration, right-canvas/modal mode routing, surface modal action rails, and reusable draggable/focus modal chrome.
17 - `initFw.js` owns Alpine bootstrap and custom lifecycle directives such as `x-create`, `x-destroy`, and periodic `x-every-*` hooks.
18 - `messages.js` owns native message/process-step rendering, safe Markdown and HTML conversion, and KaTeX delimiter handling.
19 +- `message-window.js` owns the bounded, tail-first raw-log window used by message rendering.
20 +- `scroller.js` owns bottom-following snapshots and cancellation of pending scroll effects.
21 - Other modules own focused UI utilities such as modals, messages, safe markdown, shortcuts, TTS/STT, surfaces, and initialization.
22
23 ## Local Contracts
@@ -46,6 +48,12 @@
48 - Convert standard TeX delimiters before Markdown parsing without touching inline or fenced code. Keep thought-card math rendering local to the agent-message handler rather than adding math flags to generic process-step or key/value rendering.
49 - Do not expose secrets in localStorage, console logs, URLs, or WebSocket payloads.
50 - Full message snapshots that start at backend log `no` 0 must replace the current message DOM before rendering; incremental snapshots should keep patching existing messages.
51 +- Long histories stay cached as raw log data but render a contiguous tail-first DOM window. The initial base view contains one 60-entry page; after paging, the base window contains two aligned pages, retaining the adjacent page and discarding only the far page in either direction. Visible boundaries expand to whole logical process groups so a page never reconstructs a partial group; the unit classifier must include plugin-backed process steps such as `code_exe`, and oversized groups use their own 50-step incremental window. Paging must preserve a visible anchor and occur at the scroll boundary after user intent, using passive loading indicators rather than count-bearing controls. Live entries and late content growth follow the tail until the reader deliberately moves away; historical window rebuilds must cancel pending auto-scroll effects, render in an off-screen staging history, and atomically swap fully laid-out content into the live scroller before restoring its anchor.
52 +- Message-window cache identity must keep different log types distinct even when they share a backend ID; root-agent GEN and response records intentionally use the same ID and must both survive replay, while same-ID/same-type updates still replace their earlier cached version.
53 +- Utility records join a process render unit only when a substantive process step follows before the next standalone boundary. Utility-only runs must not wrap root responses in empty groups or reopen completed groups; their group chrome stays hidden unless utility messages are enabled.
54 +- `set_messages_after_loop` receives offscreen live updates as results with `result.virtualized === true` and `result.element === null`; extensions that only need `args` may still react, while DOM-mutating extensions must guard the element.
55 +- Context switches, log GUID resets, and full log snapshots must reset both message DOM and message-window cache state.
56 +- Long primary-agent responses start collapsed only during chat replay; long user-message text starts collapsed during both live sends and replay. Both use the shared collapsible-message behavior, but user attachments remain outside its clipped content target.
57 - Info log entries with `kvps.finished` complete the active process group and clear its running treatment.
58
59 ## Work Guidance
webui/js/message-window.js new
+393
@@ -0,0 +1,393 @@
1 +const DEFAULT_INITIAL_LIMIT = 60;
2 +const DEFAULT_PAGE_SIZE = 60;
3 +const DEFAULT_MAX_WINDOW = DEFAULT_PAGE_SIZE * 2;
4 +
5 +function compareRecords(a, b) {
6 + const aNo = getRecordOrder(a.message);
7 + const bNo = getRecordOrder(b.message);
8 + return aNo - bNo || a.sequence - b.sequence;
9 +}
10 +
11 +function getRecordOrder(message) {
12 + const rawNo = message?.no;
13 + return rawNo !== undefined && rawNo !== null && Number.isFinite(Number(rawNo))
14 + ? Number(rawNo)
15 + : Number.MAX_SAFE_INTEGER;
16 +}
17 +
18 +export function getMessageCacheKey(message) {
19 + const id = message?.id;
20 + if (id !== undefined && id !== null && String(id) !== "") {
21 + // A root agent's final GEN record and its response intentionally share an
22 + // id, but they are separate log entries and both must survive replay.
23 + // Including the type still lets optimistic user messages merge with their
24 + // backend update while keeping that GEN/response pair distinct.
25 + const type = String(message?.type || "unknown");
26 + return `id:${String(id)}:type:${type}`;
27 + }
28 +
29 + const no = message?.no;
30 + if (no !== undefined && no !== null && String(no) !== "") {
31 + return `no:${String(no)}`;
32 + }
33 +
34 + return null;
35 +}
36 +
37 +const PROCESS_STEP_TYPES = new Set([
38 + "agent",
39 + "code_exe",
40 + "tool",
41 + "mcp",
42 + "subagent",
43 + "progress",
44 + "info",
45 +]);
46 +
47 +function hasUpcomingProcessStep(messages, startIndex) {
48 + for (let index = startIndex + 1; index < messages.length; index++) {
49 + const message = messages[index];
50 + const type = String(message?.type || "");
51 + if (type === "util") continue;
52 + if (PROCESS_STEP_TYPES.has(type)) return true;
53 + if (type === "warning" || type === "rate_limit") return true;
54 + if (type === "response" && Number(message?.agentno || 0) > 0) {
55 + return true;
56 + }
57 + return false;
58 + }
59 + return false;
60 +}
61 +
62 +/**
63 + * Classifies raw log entries into the same logical units that the message DOM
64 + * renderer creates. Window boundaries use these units so plugin-backed steps
65 + * such as code execution cannot split an otherwise contiguous process group.
66 + */
67 +export function classifyMessageRenderUnits(messages = []) {
68 + let activeGroup = null;
69 + let lastGroup = null;
70 + let lastUnitType = null;
71 +
72 + const startGroup = (message, index) => {
73 + const rawIdentity = message?.id !== undefined && message?.id !== null &&
74 + String(message.id) !== ""
75 + ? message.id
76 + : message?.no !== undefined && message?.no !== null
77 + ? message.no
78 + : `anonymous-${index}`;
79 + const identity = String(rawIdentity);
80 + return { id: identity, key: `process:${identity}`, complete: false };
81 + };
82 + const assignGroup = (group, isStep) => {
83 + lastGroup = group;
84 + lastUnitType = "process";
85 + return { key: group.key, group, isStep };
86 + };
87 +
88 + return messages.map((message, index) => {
89 + const type = String(message?.type || "");
90 + const standalone = {
91 + key: `entry:${getMessageCacheKey(message) || index}`,
92 + group: null,
93 + isStep: false,
94 + };
95 +
96 + if (PROCESS_STEP_TYPES.has(type)) {
97 + activeGroup ||= startGroup(message, index);
98 + const unit = assignGroup(activeGroup, true);
99 + if (type === "info" && message?.kvps?.finished) {
100 + activeGroup.complete = true;
101 + activeGroup = null;
102 + }
103 + return unit;
104 + }
105 +
106 + if (type === "util") {
107 + if (activeGroup || hasUpcomingProcessStep(messages, index)) {
108 + activeGroup ||= startGroup(message, index);
109 + return assignGroup(activeGroup, true);
110 + }
111 +
112 + // Utilities on their own must not manufacture a visible process group
113 + // around a root response. They remain standalone until a real process
114 + // step appears, and post-response utilities cannot reopen the group.
115 + activeGroup = null;
116 + lastUnitType = "standalone";
117 + return standalone;
118 + }
119 +
120 + if (type === "response" && Number(message?.agentno || 0) > 0) {
121 + activeGroup ||= startGroup(message, index);
122 + return assignGroup(activeGroup, true);
123 + }
124 +
125 + if (
126 + type === "response" &&
127 + (activeGroup || (lastUnitType === "process" && lastGroup))
128 + ) {
129 + const group = activeGroup || lastGroup;
130 + const unit = assignGroup(group, false);
131 + group.complete = true;
132 + activeGroup = null;
133 + return unit;
134 + }
135 +
136 + if ((type === "warning" || type === "rate_limit") && activeGroup) {
137 + return assignGroup(activeGroup, true);
138 + }
139 +
140 + activeGroup = null;
141 + lastUnitType = "standalone";
142 + return standalone;
143 + });
144 +}
145 +
146 +/**
147 + * Keeps the complete raw log in JavaScript while exposing a bounded contiguous
148 + * slice for DOM rendering. The class deliberately has no DOM dependencies so
149 + * window selection can be tested independently from message handlers.
150 + */
151 +export class MessageWindow {
152 + constructor({
153 + initialLimit = DEFAULT_INITIAL_LIMIT,
154 + pageSize = DEFAULT_PAGE_SIZE,
155 + maxWindow = DEFAULT_MAX_WINDOW,
156 + getUnitKeys = null,
157 + } = {}) {
158 + this.initialLimit = Math.max(1, initialLimit);
159 + this.pageSize = Math.max(1, pageSize);
160 + this.maxWindow = Math.max(this.initialLimit, maxWindow);
161 + this.getUnitKeys = typeof getUnitKeys === "function" ? getUnitKeys : null;
162 + this.reset([]);
163 + }
164 +
165 + reset(messages = []) {
166 + this._recordsByKey = new Map();
167 + this._indexByKey = new Map();
168 + this._records = [];
169 + this._nextSequence = 0;
170 + this._nextAnonymous = 0;
171 + this.start = 0;
172 + this.end = 0;
173 + this.merge(messages);
174 + this.showTail();
175 + }
176 +
177 + merge(messages = [], { followTail = true } = {}) {
178 + const previousStartKey = this._records[this.start]?.key || null;
179 + const previousEndKey = this._records[this.end - 1]?.key || null;
180 + const wasAtTail = followTail && this.end >= this._records.length;
181 + let requiresSort = false;
182 +
183 + for (const message of Array.isArray(messages) ? messages : []) {
184 + if (!message) continue;
185 + const key = getMessageCacheKey(message) ||
186 + `anonymous:${this._nextAnonymous++}`;
187 + const existing = this._recordsByKey.get(key);
188 + if (existing) {
189 + requiresSort ||=
190 + getRecordOrder(existing.message) !== getRecordOrder(message);
191 + existing.message = message;
192 + } else {
193 + const record = {
194 + key,
195 + message,
196 + sequence: this._nextSequence++,
197 + };
198 + const previous = this._records[this._records.length - 1];
199 + if (previous && compareRecords(previous, record) > 0) {
200 + requiresSort = true;
201 + }
202 + this._recordsByKey.set(key, record);
203 + this._indexByKey.set(key, this._records.length);
204 + this._records.push(record);
205 + }
206 + }
207 +
208 + if (requiresSort) {
209 + this._records.sort(compareRecords);
210 + this._rebuildIndexes();
211 + }
212 + this._rebuildRenderUnits();
213 +
214 + if (!previousStartKey || !this._records.length) {
215 + this.showTail();
216 + } else if (wasAtTail) {
217 + const previousStart = this._indexOf(previousStartKey);
218 + this.start = previousStart >= 0
219 + ? previousStart
220 + : Math.max(0, this._records.length - this.initialLimit);
221 + this.end = this._records.length;
222 + } else {
223 + const previousStart = this._indexOf(previousStartKey);
224 + const previousEnd = this._indexOf(previousEndKey);
225 + this.start = previousStart >= 0 ? previousStart : this.start;
226 + this.end = previousEnd >= 0 ? previousEnd + 1 : this.end;
227 + this._clampBounds();
228 + }
229 + }
230 +
231 + showTail() {
232 + this.end = this._records.length;
233 + this.start = Math.max(0, this.end - this.initialLimit);
234 + }
235 +
236 + showHead() {
237 + this.start = 0;
238 + this.end = Math.min(this._records.length, this.initialLimit);
239 + }
240 +
241 + compactTailIfNeeded() {
242 + if (!this.isAtTail() || this.baseRenderedCount <= this.maxWindow) {
243 + return false;
244 + }
245 + this.end = this._records.length;
246 + this.start = Math.max(0, this.end - this.maxWindow);
247 + return true;
248 + }
249 +
250 + shiftOlder() {
251 + if (!this.hasOlder) return false;
252 + const previous = this._getVisibleBounds(this.start, this.end);
253 + let nextStart = Math.max(0, this.start - this.pageSize);
254 + let nextEnd = Math.min(this._records.length, nextStart + this.maxWindow);
255 + const next = this._getVisibleBounds(nextStart, nextEnd);
256 + if (
257 + next.start === previous.start &&
258 + next.end === previous.end &&
259 + previous.start > 0
260 + ) {
261 + nextStart = Math.max(0, previous.start - this.pageSize);
262 + nextEnd = Math.min(this._records.length, nextStart + this.maxWindow);
263 + }
264 + this.start = nextStart;
265 + this.end = nextEnd;
266 + this._clampBounds();
267 + return true;
268 + }
269 +
270 + shiftNewer() {
271 + if (!this.hasNewer) return false;
272 + const previous = this._getVisibleBounds(this.start, this.end);
273 + let nextEnd = Math.min(this._records.length, this.end + this.pageSize);
274 + let nextStart = Math.max(0, nextEnd - this.maxWindow);
275 + const next = this._getVisibleBounds(nextStart, nextEnd);
276 + if (
277 + next.start === previous.start &&
278 + next.end === previous.end &&
279 + previous.end < this._records.length
280 + ) {
281 + nextEnd = Math.min(
282 + this._records.length,
283 + previous.end + this.pageSize,
284 + );
285 + nextStart = Math.max(0, nextEnd - this.maxWindow);
286 + }
287 + this.start = nextStart;
288 + this.end = nextEnd;
289 + this._clampBounds();
290 + return true;
291 + }
292 +
293 + visibleMessages() {
294 + const bounds = this._getVisibleBounds(this.start, this.end);
295 + return this._records.slice(bounds.start, bounds.end).map((record) =>
296 + record.message
297 + );
298 + }
299 +
300 + isKeyVisible(key) {
301 + if (!key) return false;
302 + const index = this._indexOf(key);
303 + const bounds = this._getVisibleBounds(this.start, this.end);
304 + return index >= bounds.start && index < bounds.end;
305 + }
306 +
307 + isAtTail() {
308 + return this.visibleEnd >= this._records.length;
309 + }
310 +
311 + get size() {
312 + return this._records.length;
313 + }
314 +
315 + get renderedCount() {
316 + return Math.max(0, this.visibleEnd - this.visibleStart);
317 + }
318 +
319 + get baseRenderedCount() {
320 + return Math.max(0, this.end - this.start);
321 + }
322 +
323 + get visibleStart() {
324 + return this._getVisibleBounds(this.start, this.end).start;
325 + }
326 +
327 + get visibleEnd() {
328 + return this._getVisibleBounds(this.start, this.end).end;
329 + }
330 +
331 + get hasOlder() {
332 + return this.visibleStart > 0;
333 + }
334 +
335 + get hasNewer() {
336 + return this.visibleEnd < this._records.length;
337 + }
338 +
339 + get olderCount() {
340 + return this.visibleStart;
341 + }
342 +
343 + get newerCount() {
344 + return Math.max(0, this._records.length - this.visibleEnd);
345 + }
346 +
347 + _indexOf(key) {
348 + if (!key) return -1;
349 + return this._indexByKey.get(key) ?? -1;
350 + }
351 +
352 + _rebuildIndexes() {
353 + this._indexByKey.clear();
354 + this._records.forEach((record, index) => {
355 + this._indexByKey.set(record.key, index);
356 + });
357 + }
358 +
359 + _rebuildRenderUnits() {
360 + const messages = this._records.map((record) => record.message);
361 + const suppliedKeys = this.getUnitKeys?.(messages);
362 + const unitKeys = Array.isArray(suppliedKeys) &&
363 + suppliedKeys.length === messages.length
364 + ? suppliedKeys
365 + : messages.map((_, index) => index);
366 +
367 + this._unitStartByIndex = new Array(messages.length);
368 + this._unitEndByIndex = new Array(messages.length);
369 + let start = 0;
370 + while (start < unitKeys.length) {
371 + let end = start + 1;
372 + while (end < unitKeys.length && unitKeys[end] === unitKeys[start]) end++;
373 + for (let index = start; index < end; index++) {
374 + this._unitStartByIndex[index] = start;
375 + this._unitEndByIndex[index] = end;
376 + }
377 + start = end;
378 + }
379 + }
380 +
381 + _getVisibleBounds(start, end) {
382 + if (!this._records.length || end <= start) return { start, end };
383 + return {
384 + start: this._unitStartByIndex?.[start] ?? start,
385 + end: this._unitEndByIndex?.[end - 1] ?? end,
386 + };
387 + }
388 +
389 + _clampBounds() {
390 + this.start = Math.max(0, Math.min(this.start, this._records.length));
391 + this.end = Math.max(this.start, Math.min(this.end, this._records.length));
392 + }
393 +}
webui/js/messages.js
+1297 -199
@@ -16,7 +16,12 @@ import {
16 getUserHour12,
17 getUserTimezone,
18 } from "./time-utils.js";
19 -import { Scroller } from "./scroller.js";
19 +import { Scroller, cancelPendingScroll } from "./scroller.js";
20 +import {
21 + MessageWindow,
22 + classifyMessageRenderUnits,
23 + getMessageCacheKey,
24 +} from "./message-window.js";
25 import { callJsExtensions } from "/js/extensions.js";
26 import { addBlankTargetsToLinks } from "/js/html-links.js";
27 import { sanitizeHtml } from "/js/safe-markdown.js";
@@ -28,13 +33,57 @@ const STEP_COLLAPSE_DELAY = {
33 };
34 // delay collapse when hovering
35 const STEP_COLLAPSE_HOVER_DELAY_MS = 5000;
36 +const PROCESS_GROUP_STEP_PAGE_SIZE = 50;
37 +const PROCESS_GROUP_RENDER_INFO = Symbol("processGroupRenderInfo");
38 +
39 +let _messageProcessGroups = new WeakMap();
40 +let _messageIsProcessStep = new WeakSet();
41 +const _processGroupStepLimits = new Map();
42 +let _renderedProcessGroupPages = new Map();
43 +
44 +function getMessageRenderUnitKeys(messages) {
45 + _messageProcessGroups = new WeakMap();
46 + _messageIsProcessStep = new WeakSet();
47 + const units = classifyMessageRenderUnits(messages);
48 + units.forEach((unit, index) => {
49 + if (!unit.group) return;
50 + _messageProcessGroups.set(messages[index], unit.group);
51 + if (unit.isStep) _messageIsProcessStep.add(messages[index]);
52 + });
53 + return units.map((unit) => unit.key);
54 +}
55
56 // dom references
57 let _chatHistory = null;
58
59 // state vars
60 let _massRender = false;
61 +let _windowedRender = false;
62 let _scrollOnNextProcessGroup = null;
63 +const _messageWindow = new MessageWindow({
64 + getUnitKeys: getMessageRenderUnitKeys,
65 +});
66 +let _messageWindowRenderPromise = null;
67 +let _messageRenderQueue = Promise.resolve();
68 +let _messageRenderGeneration = 0;
69 +let _messageWindowHistory = null;
70 +let _messageWindowScrollFrame = null;
71 +let _lastMessageWindowScrollTop = 0;
72 +let _messageWindowFollowTail = true;
73 +let _messageWindowLoadingDirection = null;
74 +let _messageWindowSuppressScrollEvents = false;
75 +let _messageWindowPointerActive = false;
76 +let _messageWindowUserScrollUntil = 0;
77 +let _messageWindowResizeObserver = null;
78 +
79 +// Leave a small tolerance for fractional scroll positions and the passive
80 +// boundary indicator, but do not swap pages while the user is still reading.
81 +const MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX = 48;
82 +const MESSAGE_WINDOW_TAIL_TOLERANCE_PX = 80;
83 +const MESSAGE_WINDOW_USER_SCROLL_GRACE_MS = 1200;
84 +const LAZY_MESSAGE_PREVIEW_CHARS = 6000;
85 +const DEFERRED_REPLAY_ENTRY_THRESHOLD = 30;
86 +const DEFERRED_REPLAY_TEXT_THRESHOLD = 50000;
87
88 /**
89 * @typedef {object} MessageHandlerArgs
@@ -138,17 +187,213 @@ export async function getMessageHandler(type) {
187
188 // entrypoint called from poll/WS communication, this is how all messages are rendered and updated
189 // input is raw log format
141 -export async function setMessages(messages) {
142 - messages = Array.isArray(messages) ? [...messages].filter(Boolean) : [];
143 - messages.sort((a, b) => (a.no ?? Number.MAX_SAFE_INTEGER) - (b.no ?? Number.MAX_SAFE_INTEGER));
190 +export function setMessages(messages) {
191 + const generation = _messageRenderGeneration;
192 + const task = _messageRenderQueue.then(
193 + () => setMessagesNow(messages, generation),
194 + () => setMessagesNow(messages, generation),
195 + );
196 + _messageRenderQueue = task.catch(() => undefined);
197 + return task;
198 +}
199
145 - const context = {
200 +async function setMessagesNow(messages, generation) {
201 + if (generation !== _messageRenderGeneration) return null;
202 + messages = normalizeMessages(messages);
203 + const history = getChatHistoryEl();
204 + const followTail = shouldFollowMessageTail();
205 +
206 + _messageWindow.merge(messages, { followTail });
207 + bindMessageWindow(history);
208 + if (_messageWindowRenderPromise) await _messageWindowRenderPromise;
209 +
210 + const initialWindow =
211 + _messageWindow.size > 0 && !history?.querySelector(".message-group");
212 + if (initialWindow && _messageWindowFollowTail) _messageWindow.showTail();
213 + const compactedTail = _messageWindow.compactTailIfNeeded();
214 + const windowMessages = _messageWindow.visibleMessages();
215 + const cappedProcessGroupUpdate = hasCappedProcessGroupUpdate(
216 messages,
147 - history: getChatHistoryEl(),
148 - historyEmpty: false,
217 + windowMessages,
218 + );
219 + if (initialWindow || compactedTail || cappedProcessGroupUpdate) {
220 + return await renderMessageWindow({
221 + preserveScroll: !initialWindow && !followTail,
222 + generation,
223 + });
224 + }
225 +
226 + return await renderMessageBatch(messages, {
227 + virtualizeOffscreen: true,
228 + windowedRender: false,
229 + generation,
230 + });
231 +}
232 +
233 +export function resetMessageRenderState({ clearDom = true } = {}) {
234 + _messageRenderGeneration += 1;
235 + _messageWindow.reset([]);
236 + _massRender = false;
237 + _windowedRender = false;
238 + _scrollOnNextProcessGroup = null;
239 + _messageWindowFollowTail = true;
240 + _messageWindowLoadingDirection = null;
241 + _messageWindowSuppressScrollEvents = false;
242 + _messageWindowPointerActive = false;
243 + _messageWindowUserScrollUntil = 0;
244 + _messageWindowResizeObserver?.disconnect();
245 + _messageWindowResizeObserver = null;
246 + _processGroupStepLimits.clear();
247 + _renderedProcessGroupPages.clear();
248 +
249 + const history = document.getElementById("chat-history") || getChatHistoryEl();
250 + if (history) cancelPendingScroll(history);
251 + if (clearDom && history) history.replaceChildren();
252 + if (history) {
253 + delete history.dataset.messageWindowStart;
254 + delete history.dataset.messageWindowEnd;
255 + delete history.dataset.messageWindowTotal;
256 + }
257 +}
258 +
259 +function normalizeMessages(messages) {
260 + const normalized = Array.isArray(messages) ? [...messages].filter(Boolean) : [];
261 + normalized.sort(
262 + (a, b) =>
263 + (a.no ?? Number.MAX_SAFE_INTEGER) -
264 + (b.no ?? Number.MAX_SAFE_INTEGER),
265 + );
266 + return normalized;
267 +}
268 +
269 +async function renderMessageWindow({
270 + preserveScroll = true,
271 + generation = _messageRenderGeneration,
272 +} = {}) {
273 + if (_messageWindowRenderPromise) return await _messageWindowRenderPromise;
274 +
275 + _messageWindowRenderPromise = (async () => {
276 + const history = getChatHistoryEl();
277 + if (!history) return null;
278 + const stagingHistory = createMessageWindowStagingHistory(history);
279 +
280 + _messageWindowSuppressScrollEvents = true;
281 + cancelPendingScroll(history);
282 + _messageWindowResizeObserver?.disconnect();
283 + try {
284 + const anchor = preserveScroll
285 + ? captureMessageWindowAnchor(history)
286 + : null;
287 + const expansionState = captureMessageExpansionState(history);
288 + _chatHistory = stagingHistory;
289 +
290 + const windowMessages = _messageWindow.visibleMessages();
291 + const renderMessages = getProcessGroupRenderMessages(windowMessages);
292 + const context = await renderMessageBatch(renderMessages, {
293 + forceHistoryEmpty: true,
294 + forceMassRender: true,
295 + suppressScroll: preserveScroll,
296 + windowedRender: shouldDeferReplayDetails(renderMessages),
297 + windowRebuild: true,
298 + generation,
299 + });
300 +
301 + if (generation !== _messageRenderGeneration) {
302 + return null;
303 + }
304 +
305 + updateProcessGroupPagingControls(stagingHistory);
306 + await restoreMessageExpansionState(stagingHistory, expansionState);
307 + await nextAnimationFrame();
308 +
309 + if (generation !== _messageRenderGeneration) return null;
310 +
311 + _messageWindowResizeObserver?.disconnect();
312 + stagingHistory
313 + .querySelectorAll(".message-container")
314 + .forEach((element) => element.classList.add("message-window-restored"));
315 + const stagedChildren = Array.from(stagingHistory.childNodes);
316 + const stagedWindowState = {
317 + messageWindowStart: stagingHistory.dataset.messageWindowStart,
318 + messageWindowEnd: stagingHistory.dataset.messageWindowEnd,
319 + messageWindowTotal: stagingHistory.dataset.messageWindowTotal,
320 + detailMode: stagingHistory.dataset.detailMode,
321 + };
322 + stagingHistory.remove();
323 + _chatHistory = history;
324 +
325 + history.replaceChildren(...stagedChildren);
326 + copyMessageWindowDataset(history, stagedWindowState);
327 + let anchorRestored = anchor
328 + ? restoreMessageWindowAnchor(history, anchor)
329 + : false;
330 + if (!anchorRestored && _messageWindow.isAtTail() && _messageWindowFollowTail) {
331 + history.scrollTop = history.scrollHeight;
332 + }
333 +
334 + await nextAnimationFrame();
335 + if (anchor) {
336 + anchorRestored = restoreMessageWindowAnchor(history, anchor) ||
337 + anchorRestored;
338 + }
339 + if (!anchorRestored && _messageWindow.isAtTail() && _messageWindowFollowTail) {
340 + history.scrollTop = history.scrollHeight;
341 + }
342 +
343 + context.history = history;
344 + context.mainScroller = null;
345 + refreshMessageWindowResizeObserver(history);
346 + return context;
347 + } finally {
348 + stagingHistory.remove();
349 + _chatHistory = history;
350 + _lastMessageWindowScrollTop = history.scrollTop;
351 + _messageWindowSuppressScrollEvents = false;
352 + }
353 + })();
354 +
355 + try {
356 + return await _messageWindowRenderPromise;
357 + } finally {
358 + _messageWindowRenderPromise = null;
359 + }
360 +}
361 +
362 +function shouldDeferReplayDetails(messages) {
363 + if (
364 + _messageWindow.hasOlder ||
365 + _messageWindow.hasNewer ||
366 + messages.length > DEFERRED_REPLAY_ENTRY_THRESHOLD
367 + ) {
368 + return true;
369 + }
370 +
371 + let textSize = 0;
372 + for (const message of messages) {
373 + textSize += String(message?.heading ?? "").length;
374 + textSize += String(message?.content ?? "").length;
375 + for (const value of Object.values(message?.kvps || {})) {
376 + textSize += typeof value === "string" ? value.length : 500;
377 + }
378 + if (textSize > DEFERRED_REPLAY_TEXT_THRESHOLD) return true;
379 + }
380 + return false;
381 +}
382 +
383 +async function renderMessageBatch(messages, options = {}) {
384 + const generation = options.generation ?? _messageRenderGeneration;
385 + if (generation !== _messageRenderGeneration) return null;
386 + const history = getChatHistoryEl();
387 + const context = {
388 + messages: normalizeMessages(messages),
389 + history,
390 + historyEmpty:
391 + options.forceHistoryEmpty ?? !history?.querySelector(".message-group"),
392 isLargeAppend: false,
393 cutoff: 0,
394 massRender: false,
395 + windowRebuild: Boolean(options.windowRebuild),
396 + messageWindow: getMessageWindowContext(),
397 scrollerOptions: {
398 smooth: true,
399 toleranceRem: 4,
@@ -161,38 +406,97 @@ export async function setMessages(messages) {
406 results: [],
407 };
408
164 - context.historyEmpty = !context.history || context.history.childElementCount === 0;
409 context.isLargeAppend = !context.historyEmpty && context.messages.length > 10;
166 - context.cutoff = context.isLargeAppend ? Math.max(0, context.messages.length - 2) : 0;
167 - context.massRender = context.historyEmpty || context.isLargeAppend;
410 + context.cutoff = context.isLargeAppend
411 + ? Math.max(0, context.messages.length - 2)
412 + : 0;
413 + context.massRender =
414 + Boolean(options.forceMassRender) ||
415 + context.historyEmpty ||
416 + context.isLargeAppend;
417 context.scrollerOptions.smooth = !context.massRender;
418
419 await callJsExtensions("set_messages_before_loop", context);
420 + if (generation !== _messageRenderGeneration) {
421 + context.history?.replaceChildren();
422 + return null;
423 + }
424
172 - //@ts-ignore
173 - context.mainScroller = new Scroller(context.history, context.scrollerOptions);
174 -
175 - // process messages
176 - for (let i = 0; i < context.messages.length; i++) {
177 - _massRender = context.historyEmpty || (context.isLargeAppend && i < context.cutoff);
178 - context.results.push(await setMessage(context.messages[i]));
425 + if (context.history) {
426 + context.mainScroller = new Scroller(
427 + context.history,
428 + context.scrollerOptions,
429 + );
430 }
431
181 - await callJsExtensions("set_messages_after_loop", context);
432 + try {
433 + for (let i = 0; i < context.messages.length; i++) {
434 + if (generation !== _messageRenderGeneration) break;
435 + const message = context.messages[i];
436 + const messageKey = getMessageCacheKey(message);
437 + if (
438 + options.virtualizeOffscreen &&
439 + messageKey &&
440 + !_messageWindow.isKeyVisible(messageKey)
441 + ) {
442 + context.results.push({
443 + args: message,
444 + result: { element: null, virtualized: true, dontScroll: true },
445 + });
446 + continue;
447 + }
448 + _massRender =
449 + Boolean(options.forceMassRender) ||
450 + context.historyEmpty ||
451 + (context.isLargeAppend && i < context.cutoff);
452 + _windowedRender = Boolean(options.windowedRender);
453 + const entry = await setMessage(message);
454 + if (generation !== _messageRenderGeneration) {
455 + context.history?.replaceChildren();
456 + break;
457 + }
458 + context.results.push(entry);
459 + }
460
183 - // reset _massRender flag
184 - _massRender = false;
461 + if (generation === _messageRenderGeneration) {
462 + updateMessageWindowIndicators(context.history);
463 + if (
464 + context.windowRebuild &&
465 + typeof preferencesStore.applyCurrentDetailMode === "function"
466 + ) {
467 + await preferencesStore.applyCurrentDetailMode(context.history);
468 + }
469 + refreshMessageWindowResizeObserver(context.history);
470 + await callJsExtensions("set_messages_after_loop", context);
471 + }
472 + } finally {
473 + _massRender = false;
474 + _windowedRender = false;
475 + }
476 +
477 + if (generation !== _messageRenderGeneration) return null;
478
186 - const shouldScroll = context.historyEmpty || !context.results[context.results.length - 1]?.result?.dontScroll;
479 + const lastResult = context.results[context.results.length - 1]?.result;
480 + const shouldScroll =
481 + !options.suppressScroll &&
482 + (context.historyEmpty || !lastResult?.dontScroll);
483
484 if (shouldScroll) context.mainScroller?.reApplyScroll();
485
486 if (_scrollOnNextProcessGroup === "scroll") {
487 requestAnimationFrame(() => {
488 + if (
489 + generation !== _messageRenderGeneration ||
490 + _scrollOnNextProcessGroup !== "scroll"
491 + ) {
492 + return;
493 + }
494 context.mainScroller?.scrollToBottom();
495 _scrollOnNextProcessGroup = null;
496 });
497 }
498 +
499 + return context;
500 }
501
502 // entrypoint called from poll/WS communication, this is how all messages are rendered and updated
@@ -212,9 +516,10 @@ export async function setMessage({
516 agentno,
517 ...additional
518 }) {
519 + const rawMessage = arguments[0];
520 const handler = await getMessageHandler(type);
521 // prefer log ID if set to match user message created on frontend with backend updates
217 - const handlerResult = await handler({
522 + const handlerArgs = {
523 no,
524 id: id || String(no) || "",
525 type,
@@ -224,9 +529,31 @@ export async function setMessage({
529 timestamp,
530 agentno,
531 ...additional,
227 - });
532 + };
533 + handlerArgs[PROCESS_GROUP_RENDER_INFO] = _messageProcessGroups.get(rawMessage);
534 + const handlerResult = await handler(handlerArgs);
535 + const messageKey = getMessageCacheKey(rawMessage);
536 +
537 + if (handlerResult?.element && messageKey) {
538 + handlerResult.element.dataset.messageKey = messageKey;
539 + }
540 + if (handlerResult?.element && no !== undefined && no !== null) {
541 + handlerResult.element.dataset.logNo = String(no);
542 + }
543 +
544 + if (handlerResult?.step) {
545 + handlerResult.step.__renderDetail = async () => {
546 + if (!handlerResult.step?.isConnected) return null;
547 + return await requestDeferredMessageDetail(rawMessage);
548 + };
549 + handlerResult.step.__discardDetail = () =>
550 + discardProcessStepDetail(handlerResult.step);
551 + handlerResult.step.__setExpanded = (expanded) =>
552 + toggleStepCollapse(handlerResult.step, expanded);
553 + }
554 +
555 return {
229 - args: arguments[0],
556 + args: rawMessage,
557 result: handlerResult,
558 }
559 }
@@ -237,7 +564,7 @@ function getOrCreateMessageContainer(
564 containerClasses = [],
565 forceNewGroup = false,
566 ) {
240 - let container = document.getElementById(`message-${id}`);
567 + let container = getChatHistoryElementById(`message-${id}`);
568 if (!container) {
569 container = document.createElement("div");
570 container.id = `message-${id}`;
@@ -260,8 +587,604 @@ function getChatHistoryEl() {
587 return _chatHistory;
588 }
589
590 +function getChatHistoryElementById(id) {
591 + const history = getChatHistoryEl();
592 + if (!history || !id) return null;
593 + if (globalThis.CSS?.escape) {
594 + return history.querySelector(`#${globalThis.CSS.escape(id)}`);
595 + }
596 + return Array.from(history.querySelectorAll("[id]")).find(
597 + (element) => element.id === id,
598 + ) || null;
599 +}
600 +
601 function getLastMessageGroup() {
264 - return getChatHistoryEl()?.lastElementChild;
602 + const groups = getChatHistoryEl()?.querySelectorAll(":scope > .message-group");
603 + return groups?.[groups.length - 1] || null;
604 +}
605 +
606 +function getMessageWindowContext() {
607 + return {
608 + start: _messageWindow.visibleStart,
609 + end: _messageWindow.visibleEnd,
610 + total: _messageWindow.size,
611 + rendered: _messageWindow.renderedCount,
612 + older: _messageWindow.olderCount,
613 + newer: _messageWindow.newerCount,
614 + hasOlder: _messageWindow.hasOlder,
615 + hasNewer: _messageWindow.hasNewer,
616 + };
617 +}
618 +
619 +function getProcessGroupPageState(messages) {
620 + const groups = new Map();
621 + for (const message of messages) {
622 + const group = _messageProcessGroups.get(message);
623 + if (!group || !_messageIsProcessStep.has(message)) continue;
624 + let state = groups.get(group.key);
625 + if (!state) {
626 + state = { group, steps: [] };
627 + groups.set(group.key, state);
628 + }
629 + state.steps.push(message);
630 + }
631 + return groups;
632 +}
633 +
634 +function getProcessGroupRenderMessages(messages) {
635 + const groups = getProcessGroupPageState(messages);
636 + const hiddenByGroup = new Map();
637 + _renderedProcessGroupPages = new Map();
638 +
639 + for (const [key, state] of groups) {
640 + const limit = _processGroupStepLimits.get(key) ||
641 + PROCESS_GROUP_STEP_PAGE_SIZE;
642 + const hidden = Math.max(0, state.steps.length - limit);
643 + hiddenByGroup.set(key, hidden);
644 + _renderedProcessGroupPages.set(key, {
645 + ...state,
646 + hidden,
647 + visible: state.steps.length - hidden,
648 + });
649 + }
650 +
651 + const seen = new Map();
652 + return messages.filter((message) => {
653 + const group = _messageProcessGroups.get(message);
654 + if (!group || !_messageIsProcessStep.has(message)) return true;
655 + const index = seen.get(group.key) || 0;
656 + seen.set(group.key, index + 1);
657 + return index >= (hiddenByGroup.get(group.key) || 0);
658 + });
659 +}
660 +
661 +function hasCappedProcessGroupUpdate(messages, windowMessages) {
662 + if (!messages.length) return false;
663 + const groupStates = getProcessGroupPageState(windowMessages);
664 + return messages.some((message) => {
665 + const group = _messageProcessGroups.get(message);
666 + if (!group || !_messageIsProcessStep.has(message)) return false;
667 + const total = groupStates.get(group.key)?.steps.length || 0;
668 + const limit = _processGroupStepLimits.get(group.key) ||
669 + PROCESS_GROUP_STEP_PAGE_SIZE;
670 + return total > limit;
671 + });
672 +}
673 +
674 +function updateProcessGroupPagingControls(history) {
675 + history
676 + ?.querySelectorAll(".process-group-show-more")
677 + .forEach((element) => element.remove());
678 +
679 + for (const [key, state] of _renderedProcessGroupPages) {
680 + const group = Array.from(
681 + history?.querySelectorAll(".process-group[data-render-group-key]") || [],
682 + ).find((candidate) => candidate.dataset.renderGroupKey === key);
683 + if (!group) continue;
684 +
685 + const allSteps = state.steps;
686 + const firstTimestamp = allSteps[0]?.timestamp;
687 + const lastTimestamp = allSteps.at(-1)?.timestamp;
688 + if (firstTimestamp != null) {
689 + group.dataset.fullStartTimestamp = String(firstTimestamp);
690 + group.setAttribute("data-start-timestamp", String(firstTimestamp));
691 + }
692 + if (lastTimestamp != null) {
693 + group.dataset.fullEndTimestamp = String(lastTimestamp);
694 + }
695 + group.dataset.fullAgentSteps = String(
696 + Math.max(
697 + 0,
698 + allSteps.filter((message) => message?.type === "agent").length - 1,
699 + ),
700 + );
701 + group.dataset.fullWarningSteps = String(
702 + allSteps.filter((message) => message?.type === "warning").length,
703 + );
704 + group.dataset.fullInfoSteps = String(
705 + allSteps.filter((message) => message?.type === "info").length,
706 + );
707 + const lastAgentMessage = allSteps.findLast(
708 + (message) => message?.type === "agent",
709 + );
710 + const fullTitle = cleanStepTitle(lastAgentMessage?.heading, 50);
711 + if (fullTitle) {
712 + const title = group.querySelector(".process-group-header .group-title");
713 + if (title) title.textContent = fullTitle;
714 + }
715 + updateProcessGroupHeader(group);
716 +
717 + if (state.hidden <= 0) continue;
718 + const stepsContainer = group.querySelector(":scope .process-steps");
719 + if (!stepsContainer) continue;
720 + const button = document.createElement("button");
721 + button.type = "button";
722 + button.className = "process-group-show-more";
723 + button.textContent = "Show more";
724 + const nextCount = Math.min(PROCESS_GROUP_STEP_PAGE_SIZE, state.hidden);
725 + button.setAttribute("aria-label", `Show ${nextCount} earlier steps`);
726 + button.addEventListener("click", () => {
727 + void showMoreProcessGroupSteps(key);
728 + });
729 + stepsContainer.insertBefore(button, stepsContainer.firstChild);
730 + }
731 +}
732 +
733 +function showMoreProcessGroupSteps(groupKey) {
734 + const generation = _messageRenderGeneration;
735 + const task = _messageRenderQueue.then(async () => {
736 + if (generation !== _messageRenderGeneration) return false;
737 + const current = _processGroupStepLimits.get(groupKey) ||
738 + PROCESS_GROUP_STEP_PAGE_SIZE;
739 + _processGroupStepLimits.set(
740 + groupKey,
741 + current + PROCESS_GROUP_STEP_PAGE_SIZE,
742 + );
743 + await renderMessageWindow({ preserveScroll: true, generation });
744 + return true;
745 + });
746 + _messageRenderQueue = task.catch(() => undefined);
747 + return task;
748 +}
749 +
750 +function shouldFollowMessageTail() {
751 + if (_messageWindow.size === 0) return true;
752 + return _messageWindowFollowTail && _messageWindow.isAtTail();
753 +}
754 +
755 +async function renderDeferredMessageDetail(message) {
756 + const entry = await setMessage(message);
757 + await callJsExtensions("set_messages_after_loop", {
758 + messages: [message],
759 + history: getChatHistoryEl(),
760 + historyEmpty: true,
761 + isLargeAppend: false,
762 + cutoff: 0,
763 + massRender: false,
764 + windowRebuild: false,
765 + detailMaterialization: true,
766 + messageWindow: getMessageWindowContext(),
767 + mainScroller: null,
768 + results: [entry],
769 + });
770 + return entry;
771 +}
772 +
773 +function requestDeferredMessageDetail(message) {
774 + if (_messageWindowRenderPromise) {
775 + return renderDeferredMessageDetail(message);
776 + }
777 +
778 + const generation = _messageRenderGeneration;
779 + const task = _messageRenderQueue.then(async () => {
780 + if (generation !== _messageRenderGeneration) return null;
781 + return await renderDeferredMessageDetail(message);
782 + });
783 + _messageRenderQueue = task.catch(() => undefined);
784 + return task;
785 +}
786 +
787 +function bindMessageWindow(history) {
788 + if (!history || _messageWindowHistory === history) return;
789 + _messageWindowHistory = history;
790 + _lastMessageWindowScrollTop = history.scrollTop;
791 +
792 + const noteUserScrollIntent = () => {
793 + _messageWindowUserScrollUntil =
794 + messageWindowNow() + MESSAGE_WINDOW_USER_SCROLL_GRACE_MS;
795 + };
796 +
797 + history.addEventListener("wheel", noteUserScrollIntent, { passive: true });
798 + history.addEventListener("touchstart", noteUserScrollIntent, {
799 + passive: true,
800 + });
801 + history.addEventListener("pointerdown", () => {
802 + _messageWindowPointerActive = true;
803 + noteUserScrollIntent();
804 + });
805 + globalThis.addEventListener("pointerup", () => {
806 + _messageWindowPointerActive = false;
807 + });
808 + globalThis.addEventListener("pointercancel", () => {
809 + _messageWindowPointerActive = false;
810 + });
811 + globalThis.addEventListener("keydown", (event) => {
812 + const target = event.target;
813 + if (
814 + target instanceof Element &&
815 + target.closest("input, textarea, select, [contenteditable='true']")
816 + ) {
817 + return;
818 + }
819 + if (
820 + ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].includes(
821 + event.key,
822 + )
823 + ) {
824 + noteUserScrollIntent();
825 + }
826 + });
827 +
828 + history.addEventListener(
829 + "scroll",
830 + () => {
831 + if (_messageWindowScrollFrame != null) return;
832 + _messageWindowScrollFrame = requestAnimationFrame(() => {
833 + _messageWindowScrollFrame = null;
834 + if (_messageWindowRenderPromise) return;
835 +
836 + const previous = _lastMessageWindowScrollTop;
837 + const current = history.scrollTop;
838 + const direction = current < previous ? "older" : current > previous ? "newer" : null;
839 + _lastMessageWindowScrollTop = current;
840 +
841 + const hasUserScrollIntent =
842 + _messageWindowPointerActive ||
843 + messageWindowNow() <= _messageWindowUserScrollUntil;
844 + const bottomDistance =
845 + history.scrollHeight - current - history.clientHeight;
846 +
847 + if (hasUserScrollIntent && direction) {
848 + _messageWindowFollowTail =
849 + _messageWindow.isAtTail() &&
850 + bottomDistance <= MESSAGE_WINDOW_TAIL_TOLERANCE_PX;
851 + }
852 +
853 + if (
854 + _messageWindowSuppressScrollEvents ||
855 + _messageWindowRenderPromise ||
856 + !hasUserScrollIntent
857 + ) {
858 + return;
859 + }
860 +
861 + if (
862 + direction === "older" &&
863 + current <= MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX &&
864 + _messageWindow.hasOlder
865 + ) {
866 + void shiftMessageWindow("older");
867 + return;
868 + }
869 +
870 + if (
871 + direction === "newer" &&
872 + bottomDistance <= MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX &&
873 + _messageWindow.hasNewer
874 + ) {
875 + void shiftMessageWindow("newer");
876 + }
877 + });
878 + },
879 + { passive: true },
880 + );
881 +}
882 +
883 +function messageWindowNow() {
884 + return globalThis.performance?.now?.() ?? Date.now();
885 +}
886 +
887 +function refreshMessageWindowResizeObserver(history) {
888 + if (!history || typeof ResizeObserver === "undefined") return;
889 + if (!_messageWindowResizeObserver) {
890 + _messageWindowResizeObserver = new ResizeObserver(() => {
891 + if (
892 + _messageWindowSuppressScrollEvents ||
893 + _messageWindowRenderPromise ||
894 + !_messageWindowFollowTail ||
895 + !_messageWindow.isAtTail()
896 + ) {
897 + return;
898 + }
899 +
900 + cancelPendingScroll(history);
901 + history.scrollTop = history.scrollHeight;
902 + _lastMessageWindowScrollTop = history.scrollTop;
903 + });
904 + }
905 +
906 + history
907 + .querySelectorAll(":scope > .message-group")
908 + .forEach((group) => _messageWindowResizeObserver.observe(group));
909 +}
910 +
911 +async function shiftMessageWindow(direction) {
912 + await loadAdjacentMessageWindow(direction);
913 +}
914 +
915 +export function loadAdjacentMessageWindow(direction) {
916 + if (!["older", "newer"].includes(direction)) {
917 + return Promise.resolve(false);
918 + }
919 + if (_messageWindowLoadingDirection) return Promise.resolve(false);
920 +
921 + const generation = _messageRenderGeneration;
922 + _messageWindowLoadingDirection = direction;
923 + setMessageWindowIndicatorLoading(getChatHistoryEl(), direction, true);
924 +
925 + const renderTask = _messageRenderQueue.then(async () => {
926 + if (generation !== _messageRenderGeneration) return false;
927 + const shifted =
928 + direction === "older"
929 + ? _messageWindow.shiftOlder()
930 + : _messageWindow.shiftNewer();
931 + if (!shifted) return false;
932 + await renderMessageWindow({ preserveScroll: true, generation });
933 + return true;
934 + });
935 + const task = renderTask.finally(() => {
936 + if (_messageWindowLoadingDirection === direction) {
937 + _messageWindowLoadingDirection = null;
938 + setMessageWindowIndicatorLoading(getChatHistoryEl(), direction, false);
939 + }
940 + });
941 + _messageRenderQueue = task.catch(() => undefined);
942 + return task;
943 +}
944 +
945 +export function scrollMessageWindowToEdge(edge) {
946 + const generation = _messageRenderGeneration;
947 + const task = _messageRenderQueue.then(async () => {
948 + if (generation !== _messageRenderGeneration) return false;
949 + const history = getChatHistoryEl();
950 +
951 + if (edge === "start") {
952 + _messageWindowFollowTail = false;
953 + cancelPendingScroll(history);
954 + if (!_messageWindow.hasOlder && _messageWindow.start === 0) {
955 + history?.scrollTo({ top: 0, behavior: "instant" });
956 + return true;
957 + }
958 + _messageWindow.showHead();
959 + await renderMessageWindow({ preserveScroll: false, generation });
960 + history?.scrollTo({ top: 0, behavior: "instant" });
961 + return true;
962 + }
963 +
964 + _messageWindowFollowTail = true;
965 + cancelPendingScroll(history);
966 + if (_messageWindow.isAtTail()) {
967 + if (history) history.scrollTop = history.scrollHeight;
968 + return true;
969 + }
970 + _messageWindow.showTail();
971 + await renderMessageWindow({ preserveScroll: false, generation });
972 + if (history) history.scrollTop = history.scrollHeight;
973 + return true;
974 + });
975 + _messageRenderQueue = task.catch(() => undefined);
976 + return task;
977 +}
978 +
979 +export function getMessageWindowState() {
980 + return getMessageWindowContext();
981 +}
982 +
983 +function updateMessageWindowIndicators(history) {
984 + if (!history) return;
985 + history
986 + .querySelectorAll(":scope > [data-message-window-ui]")
987 + .forEach((element) => element.remove());
988 +
989 + history.dataset.messageWindowStart = String(_messageWindow.visibleStart);
990 + history.dataset.messageWindowEnd = String(_messageWindow.visibleEnd);
991 + history.dataset.messageWindowTotal = String(_messageWindow.size);
992 +
993 + if (_messageWindow.hasOlder) {
994 + const older = createMessageWindowIndicator("older");
995 + history.insertBefore(older, history.firstChild);
996 + }
997 +
998 + if (_messageWindow.hasNewer) {
999 + history.appendChild(createMessageWindowIndicator("newer"));
1000 + }
1001 +}
1002 +
1003 +function setMessageWindowIndicatorLoading(history, direction, loading) {
1004 + if (!history) return;
1005 + let indicator = history.querySelector(
1006 + `:scope > [data-message-window-ui="${direction}"]`,
1007 + );
1008 + if (!indicator && loading) {
1009 + updateMessageWindowIndicators(history);
1010 + indicator = history.querySelector(
1011 + `:scope > [data-message-window-ui="${direction}"]`,
1012 + );
1013 + }
1014 + if (!indicator) return;
1015 +
1016 + indicator.classList.toggle("is-loading", loading);
1017 + if (loading) {
1018 + const label = direction === "older" ? "earlier" : "newer";
1019 + indicator.setAttribute("role", "status");
1020 + indicator.setAttribute("aria-live", "polite");
1021 + indicator.setAttribute("aria-label", `Loading ${label} messages`);
1022 + indicator.removeAttribute("aria-hidden");
1023 + } else {
1024 + indicator.removeAttribute("role");
1025 + indicator.removeAttribute("aria-live");
1026 + indicator.removeAttribute("aria-label");
1027 + indicator.setAttribute("aria-hidden", "true");
1028 + }
1029 +}
1030 +
1031 +function createMessageWindowIndicator(direction) {
1032 + const indicator = document.createElement("div");
1033 + const label = direction === "older" ? "earlier" : "newer";
1034 + const isLoading = _messageWindowLoadingDirection === direction;
1035 + indicator.className = `message-window-loader message-window-${direction}`;
1036 + indicator.classList.toggle("is-loading", isLoading);
1037 + indicator.dataset.messageWindowUi = direction;
1038 + if (isLoading) {
1039 + indicator.setAttribute("role", "status");
1040 + indicator.setAttribute("aria-live", "polite");
1041 + indicator.setAttribute("aria-label", `Loading ${label} messages`);
1042 + } else {
1043 + indicator.setAttribute("aria-hidden", "true");
1044 + }
1045 + indicator.innerHTML = `
1046 + <span class="message-window-loader-bubble" aria-hidden="true">
1047 + <span></span><span></span><span></span>
1048 + </span>
1049 + <span class="message-window-loader-label">Loading ${label} messages</span>
1050 + `;
1051 + return indicator;
1052 +}
1053 +
1054 +function createMessageWindowStagingHistory(history) {
1055 + const staging = history.cloneNode(false);
1056 + const historyRect = history.getBoundingClientRect();
1057 + staging.classList.add("message-window-staging");
1058 + staging.setAttribute("aria-hidden", "true");
1059 + staging.style.position = "fixed";
1060 + staging.style.top = "0";
1061 + staging.style.left = "-100000px";
1062 + staging.style.width = `${historyRect.width}px`;
1063 + staging.style.height = `${historyRect.height}px`;
1064 + staging.style.visibility = "hidden";
1065 + staging.style.pointerEvents = "none";
1066 + staging.style.contain = "layout style paint";
1067 + delete staging.dataset.scrollerTimeout;
1068 + delete staging.dataset.scrollerReapplySnapshot;
1069 + delete staging.dataset.scrollingTo;
1070 + history.after(staging);
1071 + return staging;
1072 +}
1073 +
1074 +function copyMessageWindowDataset(history, state) {
1075 + for (const [key, value] of Object.entries(state)) {
1076 + if (value === undefined) delete history.dataset[key];
1077 + else history.dataset[key] = value;
1078 + }
1079 +}
1080 +
1081 +function getMessageWindowAnchorCandidates(history) {
1082 + return Array.from(
1083 + history.querySelectorAll(
1084 + ".process-group[data-render-group-key], [data-message-key]",
1085 + ),
1086 + ).filter((element) =>
1087 + element.dataset.renderGroupKey || !element.closest(".process-group")
1088 + );
1089 +}
1090 +
1091 +function getMessageWindowAnchorIdentity(element) {
1092 + if (element?.dataset?.renderGroupKey) {
1093 + return `group:${element.dataset.renderGroupKey}`;
1094 + }
1095 + if (element?.dataset?.messageKey) {
1096 + return `message:${element.dataset.messageKey}`;
1097 + }
1098 + return null;
1099 +}
1100 +
1101 +function captureMessageWindowAnchor(history) {
1102 + const historyRect = history.getBoundingClientRect();
1103 + const candidates = getMessageWindowAnchorCandidates(history);
1104 + let fallback = null;
1105 +
1106 + for (const element of candidates) {
1107 + const rect = element.getBoundingClientRect();
1108 + if (rect.height <= 0 || rect.bottom <= historyRect.top) continue;
1109 + const anchor = {
1110 + identity: getMessageWindowAnchorIdentity(element),
1111 + offset: rect.top - historyRect.top,
1112 + };
1113 + if (rect.top < historyRect.bottom) return anchor;
1114 + fallback ||= anchor;
1115 + }
1116 +
1117 + return fallback;
1118 +}
1119 +
1120 +function restoreMessageWindowAnchor(history, anchor) {
1121 + if (!anchor?.identity) return false;
1122 + const historyRect = history.getBoundingClientRect();
1123 + const element = getMessageWindowAnchorCandidates(history).find(
1124 + (candidate) =>
1125 + getMessageWindowAnchorIdentity(candidate) === anchor.identity,
1126 + );
1127 + if (!element) return false;
1128 + const nextOffset = element.getBoundingClientRect().top - historyRect.top;
1129 + history.scrollTop += nextOffset - anchor.offset;
1130 + return true;
1131 +}
1132 +
1133 +function captureMessageExpansionState(history) {
1134 + const state = new Map();
1135 + history
1136 + .querySelectorAll(".process-group[id], .process-step[id]")
1137 + .forEach((element) => {
1138 + const kind = element.classList.contains("process-group")
1139 + ? "group"
1140 + : "step";
1141 + state.set(
1142 + `${kind}:${element.id}`,
1143 + element.classList.contains("expanded"),
1144 + );
1145 + });
1146 + history
1147 + .querySelectorAll("[data-message-key] > .message")
1148 + .forEach((element) => {
1149 + state.set(
1150 + `message:${element.parentElement.dataset.messageKey}`,
1151 + element.classList.contains("expanded"),
1152 + );
1153 + });
1154 + return state;
1155 +}
1156 +
1157 +async function restoreMessageExpansionState(history, state) {
1158 + const pending = [];
1159 + for (const [key, expanded] of state) {
1160 + let element = null;
1161 + if (key.startsWith("group:") || key.startsWith("step:")) {
1162 + const separator = key.indexOf(":");
1163 + const kind = key.slice(0, separator);
1164 + const id = key.slice(separator + 1);
1165 + const selector = kind === "group" ? ".process-group[id]" : ".process-step[id]";
1166 + element = Array.from(history.querySelectorAll(selector)).find(
1167 + (candidate) => candidate.id === id,
1168 + );
1169 + } else if (key.startsWith("message:")) {
1170 + const messageKey = key.slice(8);
1171 + const container = Array.from(
1172 + history.querySelectorAll("[data-message-key]"),
1173 + ).find((candidate) => candidate.dataset.messageKey === messageKey);
1174 + element = container?.querySelector(":scope > .message") || null;
1175 + }
1176 + if (!element || !history.contains(element)) continue;
1177 + if (typeof element.__setExpanded === "function") {
1178 + pending.push(Promise.resolve(element.__setExpanded(expanded)));
1179 + } else {
1180 + element.classList.toggle("expanded", expanded);
1181 + }
1182 + }
1183 + await Promise.allSettled(pending);
1184 +}
1185 +
1186 +function nextAnimationFrame() {
1187 + return new Promise((resolve) => requestAnimationFrame(() => resolve()));
1188 }
1189
1190 function appendToMessageGroup(
@@ -272,7 +1195,7 @@ function appendToMessageGroup(
1195 const chatHistoryEl = getChatHistoryEl();
1196 if (!chatHistoryEl) return;
1197
275 - const lastGroup = chatHistoryEl.lastElementChild;
1198 + const lastGroup = getLastMessageGroup();
1199 const lastGroupType = lastGroup?.getAttribute("data-group-type");
1200
1201 if (!forceNewGroup && lastGroup && lastGroupType === position) {
@@ -282,7 +1205,10 @@ function appendToMessageGroup(
1205 group.classList.add("message-group", `message-group-${position}`);
1206 group.setAttribute("data-group-type", position);
1207 group.appendChild(messageContainer);
285 - chatHistoryEl.appendChild(group);
1208 + const bottomControl = chatHistoryEl.querySelector(
1209 + ':scope > [data-message-window-ui="newer"]',
1210 + );
1211 + chatHistoryEl.insertBefore(group, bottomControl || null);
1212 }
1213 }
1214
@@ -297,9 +1223,23 @@ function getLastProcessGroup(allowCompleted = true) {
1223 return group;
1224 }
1225
300 -function getOrCreateProcessGroup(id, allowCompleted = true) {
1226 +function isUtilityOnlyProcessGroup(group) {
1227 + const steps = group?.querySelectorAll?.(".process-step") || [];
1228 + return steps.length > 0 &&
1229 + !group.querySelector(".process-step:not(.message-util)");
1230 +}
1231 +
1232 +function updateUtilityOnlyProcessGroup(group) {
1233 + if (!group) return;
1234 + const utilityOnly = isUtilityOnlyProcessGroup(group);
1235 + group.classList.toggle("utility-only", utilityOnly);
1236 + group.hidden = utilityOnly && !preferencesStore.showUtils;
1237 +}
1238 +
1239 +function getOrCreateProcessGroup(id, allowCompleted = true, renderInfo = null) {
1240 + const groupIdentity = renderInfo?.id || id;
1241 // first try direct match by ID
302 - const byId = document.getElementById(`process-group-${id}`);
1242 + const byId = getChatHistoryElementById(`process-group-${groupIdentity}`);
1243 if (byId) return byId;
1244
1245 // if not found, try to find the last process group
@@ -308,14 +1248,15 @@ function getOrCreateProcessGroup(id, allowCompleted = true) {
1248
1249 // lastly create new
1250 const messageContainer = document.createElement("div");
311 - messageContainer.id = `process-group-${id}`;
1251 + messageContainer.id = `process-group-${groupIdentity}`;
1252 messageContainer.classList.add(
1253 "message-container",
1254 "ai-container",
1255 "has-process-group",
1256 );
1257
318 - const group = createProcessGroup(id);
1258 + const group = createProcessGroup(groupIdentity);
1259 + if (renderInfo?.key) group.dataset.renderGroupKey = renderInfo.key;
1260 group.classList.add("embedded");
1261 messageContainer.appendChild(group);
1262
@@ -354,11 +1295,15 @@ export function drawProcessStep({
1295 }) {
1296 // group and steps DOM elements
1297 const stepId = `process-step-${id}`;
357 - let step = document.getElementById(stepId);
1298 + let step = getChatHistoryElementById(stepId);
1299
1300 const group =
1301 getStepProcessGroup(step) ||
361 - getOrCreateProcessGroup(id, allowCompletedGroup);
1302 + getOrCreateProcessGroup(
1303 + id,
1304 + allowCompletedGroup,
1305 + log[PROCESS_GROUP_RENDER_INFO],
1306 + );
1307 const stepsContainer = group.querySelector(".process-steps");
1308
1309 const isNewStep = !step;
@@ -455,6 +1400,8 @@ export function drawProcessStep({
1400
1401 // is step expanded?
1402 const isExpanded = step.classList.contains("expanded");
1403 + const shouldRenderDetail =
1404 + isExpanded && group.classList.contains("expanded");
1405
1406 // create step header
1407 const stepHeader = ensureChild(
@@ -464,20 +1411,14 @@ export function drawProcessStep({
1411 "process-step-header",
1412 );
1413
467 - // create step detail
1414 + // Keep the lightweight detail shell and action hooks mounted for extensions,
1415 + // but materialize text-heavy detail content only while the step is expanded.
1416 const stepDetail = ensureChild(
1417 step,
1418 ".process-step-detail",
1419 "div",
1420 "process-step-detail",
1421 );
474 - const stepDetailScroll = ensureChild(
475 - stepDetail,
476 - ".process-step-detail-scroll",
477 - "div",
478 - "process-step-detail-scroll",
479 - );
480 -
1422 // set click handlers
1423 setupProcessStepHandlers(step, stepHeader);
1424
@@ -500,32 +1441,6 @@ export function drawProcessStep({
1441 const titleEl = ensureChild(stepHeader, ".step-title", "span", "step-title");
1442 titleEl.textContent = title;
1443
503 - // auto-scroller of the step detail
504 - const detailScroller = new Scroller(stepDetailScroll, {
505 - smooth: !isMassRender(),
506 - toleranceRem: 4,
507 - }); // scroller for step detail content
508 -
509 - // update KVPs of the step detail
510 - const kvpsTable = drawKvpsIncremental(stepDetailScroll, kvps);
511 -
512 - // update content
513 - let stepDetailContent;
514 - if(content){
515 - stepDetailContent = ensureChild(
516 - stepDetailScroll,
517 - ".process-step-detail-content",
518 - "p",
519 - "process-step-detail-content",
520 - ...(contentClasses || []),
521 - );
522 - const adjustedContent = adjustStepContent(content)
523 - stepDetailContent.innerHTML = adjustedContent;
524 - }
525 -
526 - // reapply scroll position (autoscroll if bottom) - only when expanded already and not mass rendering
527 - if (isExpanded) detailScroller.reApplyScroll();
528 -
1444 // Render action buttons: get/create container, clear, append
1445 const stepActionBtns = ensureChild(
1446 stepDetail,
@@ -539,12 +1454,29 @@ export function drawProcessStep({
1454 .filter(Boolean)
1455 .forEach((button) => stepActionBtns.appendChild(button));
1456
1457 + let detailResult = {
1458 + content: undefined,
1459 + contentScroller: null,
1460 + kvpsTable: null,
1461 + };
1462 + if (shouldRenderDetail) {
1463 + detailResult = renderProcessStepDetail({
1464 + stepDetail,
1465 + kvps,
1466 + content,
1467 + contentClasses,
1468 + });
1469 + } else {
1470 + discardProcessStepDetail(step);
1471 + }
1472 +
1473 // update the process grop header by this step
1474 updateProcessGroupHeader(group);
1475 + updateUtilityOnlyProcessGroup(group);
1476
1477 // remove shine from previous steps and add to this one if new and not completed
1478 if (isNewStep && !isGroupComplete) {
547 - stepDetailScroll
1479 + group
1480 .querySelectorAll(".step-title.shiny-text")
1481 .forEach((el) => {
1482 el.classList.remove("shiny-text");
@@ -558,13 +1490,82 @@ export function drawProcessStep({
1490 actionButtons,
1491 step,
1492 detail: stepDetail,
1493 + content: detailResult.content,
1494 + contentScroller: detailResult.contentScroller,
1495 + kvpsTable: detailResult.kvpsTable,
1496 + isExpanded,
1497 + detailPending: !shouldRenderDetail,
1498 + };
1499 +}
1500 +
1501 +function renderProcessStepDetail({
1502 + stepDetail,
1503 + kvps,
1504 + content,
1505 + contentClasses,
1506 +}) {
1507 + let stepDetailScroll = stepDetail.querySelector(
1508 + ":scope > .process-step-detail-scroll",
1509 + );
1510 + if (!stepDetailScroll) {
1511 + stepDetailScroll = document.createElement("div");
1512 + stepDetailScroll.classList.add("process-step-detail-scroll");
1513 + stepDetail.insertBefore(
1514 + stepDetailScroll,
1515 + stepDetail.querySelector(":scope > .step-detail-actions"),
1516 + );
1517 + }
1518 +
1519 + const detailScroller = new Scroller(stepDetailScroll, {
1520 + smooth: !isMassRender(),
1521 + toleranceRem: 4,
1522 + });
1523 + const kvpsTable = drawKvpsIncremental(stepDetailScroll, kvps);
1524 +
1525 + let stepDetailContent;
1526 + if (content) {
1527 + stepDetailContent = ensureChild(
1528 + stepDetailScroll,
1529 + ".process-step-detail-content",
1530 + "p",
1531 + "process-step-detail-content",
1532 + ...(contentClasses || []),
1533 + );
1534 + stepDetailContent.innerHTML = adjustStepContent(content);
1535 + } else {
1536 + stepDetailScroll
1537 + .querySelector(":scope > .process-step-detail-content")
1538 + ?.remove();
1539 + }
1540 +
1541 + detailScroller.reApplyScroll();
1542 + return {
1543 content: stepDetailContent,
1544 contentScroller: detailScroller,
1545 kvpsTable,
564 - isExpanded,
1546 };
1547 }
1548
1549 +function discardProcessStepDetail(step, { force = false } = {}) {
1550 + if (!step) return;
1551 + const remove = () => {
1552 + if (!force && step.classList.contains("expanded")) return;
1553 + if (
1554 + force &&
1555 + step.classList.contains("expanded") &&
1556 + step.closest(".process-group")?.classList.contains("expanded")
1557 + ) {
1558 + return;
1559 + }
1560 + step
1561 + .querySelector(":scope > .process-step-detail > .process-step-detail-scroll")
1562 + ?.remove();
1563 + };
1564 +
1565 + if (isMassRender()) remove();
1566 + else setTimeout(remove, 250);
1567 +}
1568 +
1569 function adjustStepContent(content) {
1570 content = escapeHTML(content);
1571 content = convertPathsToLinks(content);
@@ -580,15 +1581,28 @@ function toggleStepCollapse(step, expanded) {
1581 }
1582 nextExpanded = Boolean(nextExpanded);
1583
583 - // scroll to top when collapsing
584 - if (!nextExpanded) {
585 - setTimeout(() => {
586 - const scroller = step.querySelector(".process-step-detail-scroll");
587 - if (scroller) scroller.scrollTop = 0;
588 - }, 100);
1584 + step.classList.toggle("expanded", nextExpanded);
1585 +
1586 + if (nextExpanded) {
1587 + if (step.querySelector(".process-step-detail-scroll")) return null;
1588 + return materializeProcessStepDetail(step);
1589 }
1590
591 - step.classList.toggle("expanded", nextExpanded);
1591 + const scroller = step.querySelector(".process-step-detail-scroll");
1592 + if (scroller) scroller.scrollTop = 0;
1593 + discardProcessStepDetail(step);
1594 +}
1595 +
1596 +function materializeProcessStepDetail(step) {
1597 + if (!step || typeof step.__renderDetail !== "function") return null;
1598 + if (step.__detailRenderPromise) return step.__detailRenderPromise;
1599 +
1600 + step.__detailRenderPromise = Promise.resolve(step.__renderDetail()).finally(
1601 + () => {
1602 + delete step.__detailRenderPromise;
1603 + },
1604 + );
1605 + return step.__detailRenderPromise;
1606 }
1607
1608 function drawStandaloneMessage({
@@ -695,76 +1709,43 @@ export function _drawMessage({
1709 bodyDiv.dataset.scrollStabilization = "1";
1710 const scroller = new Scroller(bodyDiv, { smooth: !isMassRender() });
1711
698 - // Handle KVPs incrementally
699 - drawKvpsIncremental(bodyDiv, kvps);
1712 + const contentText = String(content ?? "");
1713 + const lazyContent =
1714 + _windowedRender &&
1715 + contentText.length + estimateKvpTextSize(kvps) > LAZY_MESSAGE_PREVIEW_CHARS;
1716 + const contentOptions = {
1717 + bodyDiv,
1718 + content: contentText,
1719 + kvps,
1720 + contentClasses,
1721 + markdown,
1722 + latex,
1723 + smoothStream,
1724 + };
1725
701 - // Handle content
702 - if (content && content.trim().length > 0) {
703 - if (markdown) {
704 - let contentDiv = bodyDiv.querySelector(".msg-content");
705 - if (!contentDiv) {
706 - contentDiv = document.createElement("div");
707 - bodyDiv.appendChild(contentDiv);
708 - }
709 - contentDiv.className = `msg-content ${contentClasses.join(" ")}`;
710 -
711 - // let spanElement = contentDiv.querySelector("span");
712 - // if (!spanElement) {
713 - // spanElement = document.createElement("span");
714 - // contentDiv.appendChild(spanElement);
715 - // }
716 -
717 - let processedContent = content;
718 - if (latex) processedContent = convertLatexDelimiters(processedContent);
719 - processedContent = convertImageTags(processedContent);
720 - processedContent = convertImgFilePaths(processedContent);
721 - processedContent = convertFilePaths(processedContent);
722 - processedContent = marked.parse(processedContent, { breaks: true });
723 - processedContent = sanitizeHtml(processedContent, {
724 - allowDataImages: true,
725 - allowLatex: latex,
1726 + if (lazyContent) {
1727 + messageDiv.classList.add("lazy-content");
1728 + delete messageDiv.__lazyRenderedExpanded;
1729 + messageDiv.__renderLazyContent = (expanded) => {
1730 + if (messageDiv.__lazyRenderedExpanded === Boolean(expanded)) return;
1731 + messageDiv.__lazyRenderedExpanded = Boolean(expanded);
1732 + renderStandaloneMessageContent({
1733 + ...contentOptions,
1734 + content: expanded
1735 + ? contentText
1736 + : `${contentText.slice(0, LAZY_MESSAGE_PREVIEW_CHARS)}\n\n…`,
1737 + kvps: expanded ? kvps : null,
1738 + smoothStream: false,
1739 });
727 - processedContent = convertPathsToLinks(processedContent);
728 - processedContent = addBlankTargetsToLinks(processedContent);
729 -
730 - // do a smooth stream if requested
731 - if (smoothStream) smoothRender(contentDiv, processedContent);
732 - else contentDiv.innerHTML = processedContent;
733 -
734 - // KaTeX rendering for markdown
735 - if (latex) {
736 - renderLatexElements(contentDiv);
737 - }
738 -
739 - adjustMarkdownRender(contentDiv);
740 - } else {
741 - let preElement = bodyDiv.querySelector(".msg-content");
742 - if (!preElement) {
743 - preElement = document.createElement("pre");
744 - preElement.classList.add("msg-content", ...contentClasses);
745 - preElement.style.whiteSpace = "pre-wrap";
746 - preElement.style.wordBreak = "break-word";
747 - bodyDiv.appendChild(preElement);
748 - } else {
749 - // Update classes
750 - preElement.className = `msg-content ${contentClasses.join(" ")}`;
751 - }
752 -
753 - // let spanElement = preElement.querySelector("span");
754 - // if (!spanElement) {
755 - // spanElement = document.createElement("span");
756 - // preElement.appendChild(spanElement);
757 - // }
758 -
759 - if (smoothStream) smoothRender(preElement, convertHTML(content));
760 - else preElement.innerHTML = convertHTML(content);
761 - }
1740 + };
1741 + messageDiv.__renderLazyContent(
1742 + messageDiv.classList.contains("expanded"),
1743 + );
1744 } else {
763 - // Remove content if it exists but content is empty
764 - const existingContent = bodyDiv.querySelector(".msg-content");
765 - if (existingContent) {
766 - existingContent.remove();
767 - }
1745 + messageDiv.classList.remove("lazy-content");
1746 + delete messageDiv.__renderLazyContent;
1747 + delete messageDiv.__lazyRenderedExpanded;
1748 + renderStandaloneMessageContent(contentOptions);
1749 }
1750
1751 // reapply scroll position or reset for collapsed
@@ -775,6 +1756,74 @@ export function _drawMessage({
1756 return messageDiv;
1757 }
1758
1759 +function renderStandaloneMessageContent({
1760 + bodyDiv,
1761 + content,
1762 + kvps,
1763 + contentClasses,
1764 + markdown,
1765 + latex,
1766 + smoothStream,
1767 +}) {
1768 + drawKvpsIncremental(bodyDiv, kvps);
1769 + if (!content || !content.trim()) {
1770 + bodyDiv.querySelector(".msg-content")?.remove();
1771 + return;
1772 + }
1773 +
1774 + if (markdown) {
1775 + let contentDiv = bodyDiv.querySelector(".msg-content");
1776 + if (!contentDiv || contentDiv.tagName === "PRE") {
1777 + contentDiv?.remove();
1778 + contentDiv = document.createElement("div");
1779 + bodyDiv.appendChild(contentDiv);
1780 + }
1781 + contentDiv.className = `msg-content ${contentClasses.join(" ")}`;
1782 +
1783 + let processedContent = content;
1784 + if (latex) processedContent = convertLatexDelimiters(processedContent);
1785 + processedContent = convertImageTags(processedContent);
1786 + processedContent = convertImgFilePaths(processedContent);
1787 + processedContent = convertFilePaths(processedContent);
1788 + processedContent = marked.parse(processedContent, { breaks: true });
1789 + processedContent = sanitizeHtml(processedContent, {
1790 + allowDataImages: true,
1791 + allowLatex: latex,
1792 + });
1793 + processedContent = convertPathsToLinks(processedContent);
1794 + processedContent = addBlankTargetsToLinks(processedContent);
1795 +
1796 + if (smoothStream) smoothRender(contentDiv, processedContent);
1797 + else contentDiv.innerHTML = processedContent;
1798 +
1799 + if (latex) renderLatexElements(contentDiv);
1800 + adjustMarkdownRender(contentDiv);
1801 + return;
1802 + }
1803 +
1804 + let preElement = bodyDiv.querySelector(".msg-content");
1805 + if (!preElement || preElement.tagName !== "PRE") {
1806 + preElement?.remove();
1807 + preElement = document.createElement("pre");
1808 + preElement.style.whiteSpace = "pre-wrap";
1809 + preElement.style.wordBreak = "break-word";
1810 + bodyDiv.appendChild(preElement);
1811 + }
1812 + preElement.className = `msg-content ${contentClasses.join(" ")}`;
1813 +
1814 + if (smoothStream) smoothRender(preElement, convertHTML(content));
1815 + else preElement.innerHTML = convertHTML(content);
1816 +}
1817 +
1818 +function estimateKvpTextSize(kvps) {
1819 + if (!kvps) return 0;
1820 + try {
1821 + return JSON.stringify(kvps)?.length || 0;
1822 + } catch {
1823 + return LAZY_MESSAGE_PREVIEW_CHARS + 1;
1824 + }
1825 +}
1826 +
1827 export { addBlankTargetsToLinks };
1828
1829 /**
@@ -791,8 +1840,8 @@ export function drawMessageDefault({
1840 const contentText = String(content ?? "");
1841 const actionButtons = contentText.trim()
1842 ? [
794 - createActionButton("speak", "", () => ttsService.speak(contentText)),
1843 createActionButton("copy", "", () => copyToClipboard(contentText)),
1844 + createActionButton("speak", "", () => ttsService.speak(contentText)),
1845 ].filter(Boolean)
1846 : [];
1847
@@ -844,10 +1893,10 @@ export function drawMessageAgent({
1893
1894 if (thoughtsText.trim()) {
1895 actionButtons.push(
847 - createActionButton("speak", "", () => ttsService.speak(thoughtsText)),
1896 + createActionButton("copy", "", () => copyToClipboard(thoughtsText)),
1897 );
1898 actionButtons.push(
850 - createActionButton("copy", "", () => copyToClipboard(thoughtsText)),
1899 + createActionButton("speak", "", () => ttsService.speak(thoughtsText)),
1900 );
1901 }
1902
@@ -884,8 +1933,8 @@ export function drawMessageResponse({
1933 const contentText = String(content ?? "");
1934 const actionButtons = contentText.trim()
1935 ? [
887 - createActionButton("speak", "", () => ttsService.speak(contentText)),
1936 createActionButton("copy", "", () => copyToClipboard(contentText)),
1937 + createActionButton("speak", "", () => ttsService.speak(contentText)),
1938 ].filter(Boolean)
1939 : [];
1940 return drawProcessStep({
@@ -906,8 +1955,14 @@ export function drawMessageResponse({
1955 // response of agent 0, render as response to user
1956 // get last process group or create new container (if first message)
1957
909 - const group = getLastProcessGroup();
910 - let container = document.getElementById(`message-${id}`); // first check for already existing message
1958 + let group = getLastProcessGroup();
1959 + if (isUtilityOnlyProcessGroup(group)) {
1960 + group.setAttribute("data-group-complete", "true");
1961 + updateProcessGroupHeader(group);
1962 + updateUtilityOnlyProcessGroup(group);
1963 + group = null;
1964 + }
1965 + let container = getChatHistoryElementById(`message-${id}`); // first check for already existing message
1966
1967
1968 // if no container found, add to previous process group if exists
@@ -951,8 +2006,8 @@ export function drawMessageResponse({
2006 const responseText = String(content ?? "");
2007 const responseActionButtons = responseText.trim()
2008 ? [
954 - createActionButton("speak", "", () => ttsService.speak(responseText)),
2009 createActionButton("copy", "", () => copyToClipboard(responseText)),
2010 + createActionButton("speak", "", () => ttsService.speak(responseText)),
2011 ].filter(Boolean)
2012 : [];
2013 setupCollapsible(
@@ -1110,19 +2165,16 @@ export function drawMessageUser({
2165 const userText = String(content ?? "");
2166 const userActionButtons = userText.trim()
2167 ? [
1113 - createActionButton("speak", "", () => ttsService.speak(userText)),
2168 createActionButton("copy", "", () => copyToClipboard(userText)),
2169 + createActionButton("speak", "", () => ttsService.speak(userText)),
2170 ].filter(Boolean)
2171 : [];
1117 - const actionButtonsContainer = ensureChild(
2172 + setupCollapsible(
2173 messageDiv,
1119 - ".step-action-buttons",
1120 - "div",
1121 - "step-action-buttons",
1122 - );
1123 - actionButtonsContainer.textContent = "";
1124 - userActionButtons.forEach((button) =>
1125 - actionButtonsContainer.appendChild(button),
2174 + ":scope > .step-action-buttons",
2175 + false,
2176 + userActionButtons,
2177 + ":scope > .message-text",
2178 );
2179
2180 return { element: messageContainer };
@@ -1200,8 +2252,8 @@ export function drawMessageToolSimple({
2252 buildDetailPayload(arguments[0], { headerLabels }),
2253 ),
2254 ),
1203 - createActionButton("speak", "", () => ttsService.speak(contentText)),
2255 createActionButton("copy", "", () => copyToClipboard(contentText)),
2256 + createActionButton("speak", "", () => ttsService.speak(contentText)),
2257 ].filter(Boolean)
2258 : [];
2259
@@ -1245,8 +2297,8 @@ export function drawMessageMcp({
2297 buildDetailPayload(arguments[0], { headerLabels }),
2298 ),
2299 ),
1248 - createActionButton("speak", "", () => ttsService.speak(contentText)),
2300 createActionButton("copy", "", () => copyToClipboard(contentText)),
2301 + createActionButton("speak", "", () => ttsService.speak(contentText)),
2302 ].filter(Boolean)
2303 : [];
2304
@@ -1290,8 +2342,8 @@ export function drawMessageSubagent({
2342 buildDetailPayload(arguments[0], { headerLabels }),
2343 ),
2344 ),
1293 - createActionButton("speak", "", () => ttsService.speak(contentText)),
2345 createActionButton("copy", "", () => copyToClipboard(contentText)),
2346 + createActionButton("speak", "", () => ttsService.speak(contentText)),
2347 ].filter(Boolean)
2348 : [];
2349
@@ -1325,8 +2377,8 @@ export function drawMessageInfo({
2377 const contentText = String(content ?? "");
2378 const actionButtons = contentText.trim()
2379 ? [
1328 - createActionButton("speak", "", () => ttsService.speak(contentText)),
2380 createActionButton("copy", "", () => copyToClipboard(contentText)),
2381 + createActionButton("speak", "", () => ttsService.speak(contentText)),
2382 ].filter(Boolean)
2383 : [];
2384
@@ -1364,8 +2416,8 @@ export function drawMessageUtil({
2416 const contentText = String(content ?? "");
2417 const actionButtons = contentText.trim()
2418 ? [
1367 - createActionButton("speak", "", () => ttsService.speak(contentText)),
2419 createActionButton("copy", "", () => copyToClipboard(contentText)),
2420 + createActionButton("speak", "", () => ttsService.speak(contentText)),
2421 ].filter(Boolean)
2422 : [];
2423
@@ -1378,7 +2430,7 @@ export function drawMessageUtil({
2430 content,
2431 actionButtons,
2432 log: arguments[0],
1381 - allowCompletedGroup: true,
2433 + allowCompletedGroup: false,
2434 });
2435
2436 result.dontScroll = !preferencesStore.showUtils;
@@ -1403,8 +2455,8 @@ export function drawMessageHint({
2455 const contentText = String(content ?? "");
2456 const actionButtons = contentText.trim()
2457 ? [
1406 - createActionButton("speak", "", () => ttsService.speak(contentText)),
2458 createActionButton("copy", "", () => copyToClipboard(contentText)),
2459 + createActionButton("speak", "", () => ttsService.speak(contentText)),
2460 ].filter(Boolean)
2461 : [];
2462
@@ -1471,8 +2523,8 @@ export function drawMessageWarning({
2523 const contentText = String(content ?? "");
2524 const actionButtons = contentText.trim()
2525 ? [
1474 - createActionButton("speak", "", () => ttsService.speak(contentText)),
2526 createActionButton("copy", "", () => copyToClipboard(contentText)),
2527 + createActionButton("speak", "", () => ttsService.speak(contentText)),
2528 ].filter(Boolean)
2529 : [];
2530
@@ -1559,7 +2611,7 @@ function drawKvpsIncremental(container, kvps) {
2611 if (!table) {
2612 table = document.createElement("table");
2613 table.classList.add("msg-kvps");
1562 - container.appendChild(table);
2614 + container.insertBefore(table, container.firstChild);
2615 }
2616
2617 // Get all current rows for comparison
@@ -1886,9 +2938,29 @@ function createProcessGroup(id) {
2938 </span>
2939 `;
2940
2941 + group.__setExpanded = (expanded) => {
2942 + const nextExpanded = Boolean(expanded);
2943 + group.classList.toggle("expanded", nextExpanded);
2944 + const steps = group.querySelectorAll(".process-step");
2945 + if (nextExpanded) {
2946 + steps.forEach((step) => {
2947 + if (
2948 + step.classList.contains("expanded") &&
2949 + !step.querySelector(".process-step-detail-scroll")
2950 + ) {
2951 + void materializeProcessStepDetail(step);
2952 + }
2953 + });
2954 + } else {
2955 + steps.forEach((step) =>
2956 + discardProcessStepDetail(step, { force: true }),
2957 + );
2958 + }
2959 + };
2960 +
2961 // Add click handler for expansion
2962 header.addEventListener("click", () => {
1891 - group.classList.toggle("expanded");
2963 + group.__setExpanded(!group.classList.contains("expanded"));
2964 });
2965
2966 group.appendChild(header);
@@ -2135,10 +3207,13 @@ function updateProcessGroupHeader(group) {
3207 const stepsMetricValEl =
3208 stepMetricContainerEl?.querySelector(".metric-value");
3209 if (stepsMetricValEl) {
2138 - let genSteps = group.querySelectorAll(
2139 - '.process-step[data-log-type="agent"]',
2140 - ).length;
2141 - genSteps -= 1; // don't count response as step
3210 + let genSteps = Number(group.dataset.fullAgentSteps);
3211 + if (!Number.isFinite(genSteps)) {
3212 + genSteps = group.querySelectorAll(
3213 + '.process-step[data-log-type="agent"]',
3214 + ).length;
3215 + genSteps -= 1; // don't count response as step
3216 + }
3217 stepsMetricValEl.textContent = genSteps.toString();
3218 if (genSteps <= 0)
3219 stepMetricContainerEl.classList.add("display-none"); // hide when no steps
@@ -2165,14 +3240,15 @@ function updateProcessGroupHeader(group) {
3240 }
3241 }
3242
2168 - const firstTimestampMs = parseInt(
2169 - steps[0]?.getAttribute("data-timestamp") || "0",
2170 - 10,
2171 - );
2172 - const lastTimestampMs = parseInt(
2173 - steps[steps.length - 1]?.getAttribute("data-timestamp") || "0",
2174 - 10,
2175 - );
3243 + const firstTimestampMs = group.dataset.fullStartTimestamp
3244 + ? Math.round(Number(group.dataset.fullStartTimestamp) * 1000)
3245 + : parseInt(steps[0]?.getAttribute("data-timestamp") || "0", 10);
3246 + const lastTimestampMs = group.dataset.fullEndTimestamp
3247 + ? Math.round(Number(group.dataset.fullEndTimestamp) * 1000)
3248 + : parseInt(
3249 + steps[steps.length - 1]?.getAttribute("data-timestamp") || "0",
3250 + 10,
3251 + );
3252 const durationText =
3253 isCompleted &&
3254 metricsEl &&
@@ -2193,13 +3269,20 @@ function updateProcessGroupHeader(group) {
3269 }
3270
3271 if (notificationsEl) {
2196 - const counts = { warning: 0, info: 0 };
2197 - steps.forEach((step) => {
2198 - const stepType = step.getAttribute("data-log-type");
2199 - if (Object.prototype.hasOwnProperty.call(counts, stepType)) {
2200 - counts[stepType] += 1;
2201 - }
2202 - });
3272 + const fullWarningSteps = Number(group.dataset.fullWarningSteps);
3273 + const fullInfoSteps = Number(group.dataset.fullInfoSteps);
3274 + const counts = Number.isFinite(fullWarningSteps) &&
3275 + Number.isFinite(fullInfoSteps)
3276 + ? { warning: fullWarningSteps, info: fullInfoSteps }
3277 + : { warning: 0, info: 0 };
3278 + if (!Number.isFinite(fullWarningSteps) || !Number.isFinite(fullInfoSteps)) {
3279 + steps.forEach((step) => {
3280 + const stepType = step.getAttribute("data-log-type");
3281 + if (Object.prototype.hasOwnProperty.call(counts, stepType)) {
3282 + counts[stepType] += 1;
3283 + }
3284 + });
3285 + }
3286
3287 const totalNotifications = counts.warning + counts.info;
3288 const countEl = notificationsEl.querySelector(".metric-value");
@@ -2277,9 +3360,17 @@ function setupCollapsible(
3360 containerSelector,
3361 initialExpanded,
3362 actionButtons = [],
3363 + contentSelector = ":scope > .message-body",
3364 ) {
3365 messageDiv.classList.add("message-collapsible");
2282 - messageDiv.classList.toggle("expanded", initialExpanded);
3366 + messageDiv
3367 + .querySelectorAll(":scope > .message-collapse-content")
3368 + .forEach((element) => element.classList.remove("message-collapse-content"));
3369 + const collapseContent = messageDiv.querySelector(contentSelector);
3370 + collapseContent?.classList.add("message-collapse-content");
3371 + const initialState =
3372 + Boolean(initialExpanded) && !messageDiv.classList.contains("lazy-content");
3373 + messageDiv.classList.toggle("expanded", initialState);
3374
3375 const container = ensureChild(
3376 messageDiv,
@@ -2296,28 +3387,35 @@ function setupCollapsible(
3387 btn.classList.toggle("show-less-btn", exp);
3388 btn.classList.toggle("show-more-btn", !exp);
3389 };
2299 - syncBtn();
2300 - btn.onclick = () => {
2301 - messageDiv.classList.toggle("expanded");
3390 + const setExpanded = (expanded) => {
3391 + const nextExpanded = Boolean(expanded);
3392 + messageDiv.classList.toggle("expanded", nextExpanded);
3393 + messageDiv.__renderLazyContent?.(nextExpanded);
3394 syncBtn();
2303 - messageDiv.classList.contains("expanded") ||
2304 - (messageDiv.querySelector(".message-body").scrollTop = 0);
3395 + if (!nextExpanded) {
3396 + if (collapseContent) collapseContent.scrollTop = 0;
3397 + }
3398 };
3399 + messageDiv.__setExpanded = setExpanded;
3400 + setExpanded(initialState);
3401 + btn.onclick = () =>
3402 + setExpanded(!messageDiv.classList.contains("expanded"));
3403
3404 actionButtons.filter(Boolean).forEach((b) => container.appendChild(b));
3405
3406 // Detect overflow after render
3407 requestAnimationFrame(() => {
2311 - const body = messageDiv.querySelector(".message-body");
3408 const fontSize = parseFloat(
2313 - getComputedStyle(body || document.documentElement).fontSize || "16",
3409 + getComputedStyle(collapseContent || document.documentElement).fontSize ||
3410 + "16",
3411 );
3412 const maxHeight = messageDiv.classList.contains("expanded")
3413 ? fontSize * 15
2317 - : body?.clientHeight || 0;
3414 + : collapseContent?.clientHeight || 0;
3415 messageDiv.classList.toggle(
3416 "has-overflow",
2320 - (body?.scrollHeight || 0) > maxHeight,
3417 + messageDiv.classList.contains("lazy-content") ||
3418 + (collapseContent?.scrollHeight || 0) > maxHeight,
3419 );
3420 });
3421 }
webui/js/scroller.js
+21
@@ -1,3 +1,24 @@
1 +/**
2 + * Stop delayed or in-flight scrolling previously scheduled for an element.
3 + * Virtualized containers call this before replacing their children so an old
4 + * auto-scroll cannot override the restored viewport anchor.
5 + */
6 +export function cancelPendingScroll(element) {
7 + if (!element) return;
8 +
9 + const timeoutRaw = element.dataset?.scrollerTimeout;
10 + const timeoutId = timeoutRaw == null ? null : Number(timeoutRaw);
11 + if (Number.isFinite(timeoutId)) clearTimeout(timeoutId);
12 +
13 + if (element.dataset?.scrollingTo != null && element.scrollTo) {
14 + element.scrollTo({ top: element.scrollTop, behavior: "instant" });
15 + }
16 +
17 + delete element.dataset.scrollerTimeout;
18 + delete element.dataset.scrollerReapplySnapshot;
19 + delete element.dataset.scrollingTo;
20 +}
21 +
22 export class Scroller {
23 constructor(
24 element,