Add tool request validation and plugin change notifications

Introduce validate_tool_request() extensible method in agent.py to validate tool request structure (dict with tool_name string and tool_args dict fields) before processing. Add after_plugin_change() helper in helpers/plugins.py that clears cache and sends a frontend reload notification (throttled to display_time interval) with a reload button. Update plugin installer install/delete flows to call after_plugin_change(). Extend notification

frdel committed Mar 10, 2026 at 13:08 UTC 1b89a0d35923136d0014e0dded7a13e4b20265c1
24 files changed +315 -137
agent.py
+14
@@ -854,6 +854,9 @@ class Agent:
854 # search for tool usage requests in agent message
855 tool_request = extract_tools.json_parse_dirty(msg)
856
857 + # basic validation + extensions
858 + await self.validate_tool_request(tool_request)
859 +
860 if tool_request is not None:
861 raw_tool_name = tool_request.get("tool_name", tool_request.get("tool","")) # Get the raw tool name
862 tool_args = tool_request.get("tool_args", tool_request.get("args", {}))
@@ -948,6 +951,17 @@ class Agent:
951 content=f"{self.agent_name}: Message misformat, no valid tool request found.",
952 )
953
954 + @extension.extensible
955 + async def validate_tool_request(self, tool_request: Any):
956 + if not isinstance(tool_request, dict):
957 + raise ValueError("Tool request must be a dictionary")
958 + if not tool_request.get("tool_name") or not isinstance(tool_request.get("tool_name"), str):
959 + raise ValueError("Tool request must have a tool_name (type string) field")
960 + if not tool_request.get("tool_args") or not isinstance(tool_request.get("tool_args"), dict):
961 + raise ValueError("Tool request must have a tool_args (type dictionary) field")
962 +
963 +
964 +
965 async def handle_reasoning_stream(self, stream: str):
966 await self.handle_intervention()
967 await extension.call_extensions_async(
api/notification_create.py
+2
@@ -17,6 +17,7 @@ class NotificationCreate(ApiHandler):
17 detail = input.get("detail", "")
18 display_time = input.get("display_time", 3) # Default to 3 seconds
19 group = input.get("group", "") # Group parameter for notification grouping
20 + notification_id = input.get("id", "")
21
22 # Validate required fields
23 if not message:
@@ -50,6 +51,7 @@ class NotificationCreate(ApiHandler):
51 detail,
52 display_time,
53 group,
54 + notification_id,
55 )
56
57 return {
helpers/notification.py
+37 -20
@@ -77,10 +77,11 @@ class NotificationManager:
77 detail: str = "",
78 display_time: int = 3,
79 group: str = "",
80 + id: str = "",
81 ) -> NotificationItem:
82 from agent import AgentContext
83 return AgentContext.get_notification_manager().add_notification(
83 - type, priority, message, title, detail, display_time, group
84 + type, priority, message, title, detail, display_time, group, id
85 )
86
87 def add_notification(
@@ -92,28 +93,44 @@ class NotificationManager:
93 detail: str = "",
94 display_time: int = 3,
95 group: str = "",
96 + id: str = "",
97 ) -> NotificationItem:
98 with self._lock:
99 + existing = None
100 + if id:
101 + existing = next((n for n in self.notifications if n.id == id), None)
102 +
103 + if existing:
104 + existing.type = NotificationType(type)
105 + existing.priority = NotificationPriority(priority)
106 + existing.title = title
107 + existing.message = message
108 + existing.detail = detail
109 + existing.timestamp = datetime.now(timezone.utc)
110 + existing.display_time = display_time
111 + existing.group = group
112 + existing.read = False
113 + self.updates.append(existing.no)
114 + item = existing
115 + else:
116 # Create notification item
98 - item = NotificationItem(
99 - manager=self,
100 - no=len(self.notifications),
101 - type=NotificationType(type),
102 - priority=NotificationPriority(priority),
103 - title=title,
104 - message=message,
105 - detail=detail,
106 - timestamp=datetime.now(timezone.utc),
107 - display_time=display_time,
108 - group=group,
109 - )
110 -
111 - # Add to notifications
112 - self.notifications.append(item)
113 - self.updates.append(item.no)
114 -
115 - # Enforce limit
116 - self._enforce_limit()
117 + item = NotificationItem(
118 + manager=self,
119 + no=len(self.notifications),
120 + type=NotificationType(type),
121 + priority=NotificationPriority(priority),
122 + title=title,
123 + message=message,
124 + detail=detail,
125 + timestamp=datetime.now(timezone.utc),
126 + display_time=display_time,
127 + id=id,
128 + group=group,
129 + )
130 +
131 + self.notifications.append(item)
132 + self.updates.append(item.no)
133 + self._enforce_limit()
134
135 from helpers.state_monitor_integration import mark_dirty_all
136 mark_dirty_all(reason="notification.NotificationManager.add_notification")
helpers/plugins.py
+36 -5
@@ -1,6 +1,7 @@
1 from __future__ import annotations
2
3 import re, json, glob
4 +import time
5 from pathlib import Path
6 from typing import (
7 Any,
@@ -13,7 +14,7 @@ from typing import (
14 TypedDict,
15 )
16
16 -from helpers import files, print_style, yaml as yaml_helper, cache
17 +from helpers import files, notification, print_style, yaml as yaml_helper, cache
18 from pydantic import BaseModel, Field
19
20 if TYPE_CHECKING:
@@ -40,6 +41,7 @@ CONFIG_DEFAULT_FILE_NAME = "default_config.yaml"
41 DISABLED_FILE_NAME = ".toggle-0"
42 ENABLED_FILE_NAME = ".toggle-1"
43 TOGGLE_FILE_PATTERN = ".toggle-[01]"
44 +_last_frontend_reload_notification_at = 0.0
45
46
47 class PluginMetadata(BaseModel):
@@ -71,6 +73,11 @@ class PluginListItem(BaseModel):
73 toggle_state: ToggleState = "disabled"
74
75
76 +def after_plugin_change():
77 + clear_plugin_cache()
78 + send_frontend_reload_notification()
79 +
80 +
81 def clear_plugin_cache():
82 cache.clear("*(plugins)*")
83
@@ -189,7 +196,7 @@ def delete_plugin(plugin_name: str):
196 if not files.is_in_dir(plugin_dir, custom_plugins_dir):
197 raise ValueError("Only custom plugins can be deleted")
198 files.delete_dir(plugin_dir)
192 - clear_plugin_cache()
199 + after_plugin_change()
200
201
202 def get_plugin_paths(*subpaths: str) -> List[str]:
@@ -348,7 +355,7 @@ def toggle_plugin(
355 files.write_file(enabled_file, "")
356 else:
357 files.write_file(disabled_file, "")
351 - clear_plugin_cache()
358 + after_plugin_change()
359
360
361 def get_plugin_config(
@@ -387,7 +394,9 @@ def get_plugin_config(
394
395
396 def get_default_plugin_config(plugin_name: str):
390 - file_path = files.get_abs_path(find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME)
397 + file_path = files.get_abs_path(
398 + find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
399 + )
400 if file_path and files.exists(file_path):
401 return (
402 json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
@@ -403,7 +412,7 @@ def save_plugin_config(
412 )
413 if file_path:
414 files.write_file(file_path, json.dumps(settings))
406 - clear_plugin_cache()
415 + after_plugin_change()
416
417
418 def find_plugin_asset(
@@ -548,3 +557,25 @@ def determine_plugin_asset_path(
557 base_path = files.get_abs_path(base_path, files.AGENTS_DIR, agent_profile)
558
559 return files.get_abs_path(base_path, files.PLUGINS_DIR, plugin_name, *subpaths)
560 +
561 +
562 +def send_frontend_reload_notification():
563 + global _last_frontend_reload_notification_at
564 +
565 + display_time = 3
566 + now = time.monotonic()
567 + if now - _last_frontend_reload_notification_at < display_time:
568 + return
569 +
570 + _last_frontend_reload_notification_at = now
571 +
572 + notification.NotificationManager.send_notification(
573 + type=notification.NotificationType.INFO,
574 + priority=notification.NotificationPriority.NORMAL,
575 + title="Plugins updated, page reload recommended",
576 + message="""<button type="button" class="button confirm" onclick="window.location.reload()"><span class="icon material-symbols-outlined">refresh</span>Reload page</button>""",
577 + detail="",
578 + display_time=display_time,
579 + group="plugins_changed",
580 + id="plugins_frontend_reload",
581 + )
plugins/error_retry/default_config.yaml new
+1
@@ -0,0 +1 @@
1 +retries: 1
\ No newline at end of file
plugins/error_retry/webui/config.html new
+32
@@ -0,0 +1,32 @@
1 +<html>
2 +<head>
3 + <title>Error retry</title>
4 +</head>
5 +
6 +<body>
7 + <div x-data>
8 + <template x-if="config">
9 + <div>
10 + <div class="section-title">Error retry</div>
11 + <div class="section-description">
12 + Settings for retrying failed operations.
13 + </div>
14 +
15 + <div class="field">
16 + <div class="field-label">
17 + <div class="field-title">Retries</div>
18 + <div class="field-description">
19 + Number of retries after an error occurs.
20 + </div>
21 + </div>
22 + <div class="field-control">
23 + <input type="number" min="0"
24 + x-model.number="config.retries" />
25 + </div>
26 + </div>
27 + </div>
28 + </template>
29 + </div>
30 +</body>
31 +
32 +</html>
plugins/memory/webui/memory-detail-modal.html
-39
@@ -163,45 +163,6 @@
163 border: 1px solid;
164 }
165
166 - .btn-action-header {
167 - padding: 0.4rem 0.6rem;
168 - display: flex;
169 - align-items: center;
170 - gap: 0.25rem;
171 - background: var(--color-message-bg);
172 - color: var(--color-text);
173 - border: 1px solid var(--color-border);
174 - cursor: pointer;
175 - }
176 -
177 - .btn-action-header .material-symbols-outlined {
178 - font-size: 18px;
179 - }
180 -
181 - .btn-action-header.delete:hover {
182 - border-color: var(--color-accent);
183 - color: var(--color-accent);
184 - }
185 -
186 - .btn-action-header.copy-all:hover,
187 - .btn-action-header.copy-content:hover,
188 - .btn-action-header.edit:hover {
189 - border-color: var(--color-primary);
190 - color: var(--color-primary);
191 - }
192 -
193 - .btn-action-header.confirm:hover {
194 - border-color: #4CAF50;
195 - color: #4CAF50;
196 - background: var(--color-background);
197 - }
198 -
199 - .btn-action-header.cancel:hover {
200 - border-color: var(--color-accent);
201 - color: var(--color-accent);
202 - background: var(--color-background);
203 - }
204 -
166 .modal-body {
167 display: flex;
168 min-height: 60vh;
plugins/plugin_installer/helpers/install.py
+3 -3
@@ -18,7 +18,7 @@ from helpers.plugins import (
18 META_FILE_NAME,
19 PluginMetadata,
20 get_plugins_list,
21 - clear_plugin_cache,
21 + after_plugin_change,
22 )
23 from werkzeug.datastructures import FileStorage
24 from werkzeug.utils import secure_filename
@@ -138,7 +138,7 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
138 dest = os.path.join(_get_user_plugins_dir(), plugin_name)
139 os.makedirs(os.path.dirname(dest), exist_ok=True)
140 shutil.move(plugin_root, dest)
141 - clear_plugin_cache()
141 + after_plugin_change()
142
143 return {
144 "success": True,
@@ -181,7 +181,7 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
181 shutil.rmtree(dest, ignore_errors=True)
182 raise
183
184 - clear_plugin_cache()
184 + after_plugin_change()
185
186 return {
187 "success": True,
webui/components/chat/input/chat-bar-input.html
+3 -5
@@ -114,17 +114,15 @@
114
115 /* Chat buttons (Send/Mic) */
116 #chat-buttons-wrapper { gap: var(--spacing-xs); padding-left: var(--spacing-xs); line-height: 0.5rem; display: -webkit-flex; display: flex; }
117 - .chat-button { border: none; border-radius: 50%; color: var(--color-background); cursor: pointer; font-size: var(--font-size-normal); height: 2.525rem; width: 2.525rem; margin: 0 0.18rem 0 0 var(--spacing-xs); display: -webkit-flex; display: flex; align-items: center; justify-content: center; flex-shrink: 0; flex-grow: 0; min-width: 2.525rem; -webkit-transition: all var(--transition-speed), transform 0.1s ease-in-out; transition: all var(--transition-speed), transform 0.1s ease-in-out; }
117 + .chat-button { border: none; border-radius: 50%; color: var(--color-background); cursor: pointer; font-size: var(--font-size-normal); height: 2.525rem; width: 2.525rem; margin: 0 0.18rem 0 0 var(--spacing-xs); display: -webkit-flex; display: flex; align-items: center; justify-content: center; flex-shrink: 0; flex-grow: 0; min-width: 2.525rem; -webkit-transition: background-color var(--transition-speed), box-shadow 0.12s ease-in-out, filter 0.12s ease-in-out; transition: background-color var(--transition-speed), box-shadow 0.12s ease-in-out, filter 0.12s ease-in-out; }
118 #send-button { background-color: #4248f1; }
119 #send-button.send-queue { background-color: #e67e22; }
120 - #send-button:hover { -webkit-transform: scale(1.05); transform: scale(1.05); transform-origin: center; background-color: #353bc5; }
120 + #send-button:hover { background-color: #353bc5; box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 6px 14px rgba(0,0,0,0.2); }
121 #send-button.send-queue:hover { background-color: #d35400; }
122 - #send-button:active { -webkit-transform: scale(1); transform: scale(1); transform-origin: center; background-color: #2b309c; }
122 + #send-button:active { background-color: #2b309c; filter: brightness(0.96); }
123 .chat-button svg { width: 1.5rem; height: 1.5rem; }
124 #send-button .material-symbols-outlined { font-size: 1.5rem; }
125
126 - /* Microphone button */
127 - .chat-button.mic-inactive svg { /* Add specific styles if needed */ }
126 /* Responsive tweaks */
127 @media (max-width: 640px) {
128 #chat-input { min-height: 5.3rem; align-content: start; }
webui/components/chat/message-queue/message-queue.html
+4 -3
@@ -95,17 +95,18 @@
95 display: flex;
96 align-items: center;
97 opacity: 0.6;
98 - transition: all 0.1s ease-in-out;
98 + transition: opacity 0.1s ease-in-out, color 0.1s ease-in-out,
99 + box-shadow 0.1s ease-in-out;
100 }
101
102 .queue-header-btn:hover {
103 opacity: 1;
103 - transform: scale(1.1);
104 + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent);
105 }
106
107 .queue-header-btn:active {
108 opacity: 0.5;
108 - transform: scale(0.95);
109 + box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.06);
110 }
111
112 .queue-header-btn .material-symbols-outlined {
webui/components/messages/process-group/process-group.css
+2 -2
@@ -844,13 +844,13 @@
844 border-radius: 4px;
845 border: 1px solid rgba(255, 255, 255, 0.1);
846 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
847 - transition: transform 0.2s ease, box-shadow 0.2s ease;
847 + transition: box-shadow 0.2s ease, filter 0.2s ease;
848 object-fit: contain;
849 }
850
851 .process-step-detail-scroll .screenshot-img:hover {
852 - transform: scale(1.02);
852 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
853 + filter: brightness(1.02);
854 }
855
856 /* Light mode screenshot border */
webui/components/notifications/notification-modal.html
+1 -1
@@ -38,7 +38,7 @@
38 <div class="notification-content">
39 <div class="notification-title" x-show="notification.title" x-text="notification.title">
40 </div>
41 - <div class="notification-message" x-text="notification.message">
41 + <div class="notification-message" x-html="notification.message">
42 </div>
43 <div class="notification-timestamp"
44 x-text="$store.notificationStore?.formatTimestamp(notification.timestamp) || notification.timestamp">
webui/components/notifications/notification-store.js
+59 -7
@@ -21,7 +21,7 @@ const maxNotifications = 100;
21 const maxToasts = 5;
22
23 const model = {
24 - notifications: [],
24 + notifications: Array(),
25 loading: false,
26 lastNotificationVersion: 0,
27 lastNotificationGuid: "",
@@ -29,7 +29,7 @@ const model = {
29 unreadPrioCount: 0,
30
31 // NEW: Toast stack management
32 - toastStack: [],
32 + toastStack: Array(),
33
34 init() {
35 this.initialize();
@@ -70,11 +70,26 @@ const model = {
70 // adjust notification data before adding
71 this.adjustNotificationData(notification);
72
73 - const isNew = !this.notifications.find((n) => n.id === notification.id);
73 + const existingNotificationIndex = this.notifications.findIndex(
74 + (n) => n.id === notification.id
75 + );
76 + const existingNotification =
77 + existingNotificationIndex >= 0
78 + ? this.notifications[existingNotificationIndex]
79 + : null;
80 + const isNew = !existingNotification;
81 + const shouldRetoast =
82 + !!existingNotification &&
83 + shouldToast &&
84 + (existingNotification.timestamp !== notification.timestamp ||
85 + existingNotification.title !== notification.title ||
86 + existingNotification.message !== notification.message ||
87 + existingNotification.detail !== notification.detail);
88 +
89 this.addOrUpdateNotification(notification);
90
76 - // Add new unread notifications to toast stack
77 - if (isNew && shouldToast) {
91 + // Add new unread notifications to toast stack, and also re-toast updated unread notifications
92 + if ((isNew && shouldToast) || shouldRetoast) {
93 this.addToToastStack(notification);
94 }
95 });
@@ -125,9 +140,30 @@ const model = {
140 }
141
142 // Set auto-dismiss timer
143 + this.restartToastTimer(toast.toastId);
144 + },
145 +
146 + clearToastTimer(toastId) {
147 + const toastIndex = this.toastStack.findIndex((t) => t.toastId === toastId);
148 + if (toastIndex < 0) return;
149 +
150 + const toast = this.toastStack[toastIndex];
151 + if (toast.autoRemoveTimer) {
152 + clearTimeout(toast.autoRemoveTimer);
153 + toast.autoRemoveTimer = null;
154 + }
155 + },
156 +
157 + restartToastTimer(toastId) {
158 + const toastIndex = this.toastStack.findIndex((t) => t.toastId === toastId);
159 + if (toastIndex < 0) return;
160 +
161 + const toast = this.toastStack[toastIndex];
162 +
163 + this.clearToastTimer(toastId);
164 toast.autoRemoveTimer = setTimeout(() => {
165 this.removeFromToastStack(toast.toastId);
130 - }, notification.display_time * 1000);
166 + }, toast.display_time * 1000);
167 },
168
169 // NEW: Remove toast from stack
@@ -186,7 +222,21 @@ const model = {
222 },
223
224 // NEW: Handle toast click (opens modal)
189 - async handleToastClick(toastId) {
225 + async handleToastClick(toastId, event) {
226 + const target = event?.target;
227 + const toast = this.toastStack.find((t) => t.toastId === toastId);
228 + if (
229 + target instanceof Element &&
230 + target.closest(
231 + 'button, a, input, select, textarea, summary, label, [role="button"], [data-toast-interactive]'
232 + )
233 + ) {
234 + if (toast?.id) {
235 + this.markAsRead(toast.id);
236 + }
237 + return;
238 + }
239 +
240 await this.openModal();
241 // Modal opening will clear toast stack via markAllAsRead
242 },
@@ -587,6 +637,8 @@ const model = {
637 toastId: `toast-${notification.id}`,
638 addedAt: Date.now(),
639 autoRemoveTimer: null,
640 + hoverTimer: null,
641 + isHovered: false,
642 };
643
644 // Add to bottom of stack (newest at bottom)
webui/components/notifications/notification-toast-stack.html
+8 -5
@@ -15,7 +15,9 @@
15 <template x-for="(toast, index) in $store.notificationStore.toastStack" :key="toast.toastId">
16 <div class="toast-item"
17 :class="$store.notificationStore.getNotificationClass(toast.type)"
18 - @click="$store.notificationStore.handleToastClick(toast.toastId)"
18 + @click="$store.notificationStore.handleToastClick(toast.toastId, $event)"
19 + @mouseenter="$store.notificationStore.clearToastTimer(toast.toastId)"
20 + @mouseleave="$store.notificationStore.restartToastTimer(toast.toastId)"
21 x-transition:enter="toast-enter"
22 x-transition:leave="toast-leave">
23
@@ -31,7 +33,7 @@
33 x-text="toast.title">
34 </div>
35 <div class="toast-message"
34 - x-text="toast.message">
36 + x-html="toast.message">
37 </div>
38 <!-- <div class="toast-timestamp"
39 x-text="$store.notificationStore.formatTimestamp(toast.timestamp)">
@@ -77,7 +79,8 @@
79 border: 1px solid var(--color-border);
80 border-radius: 8px;
81 cursor: pointer;
80 - transition: all 0.2s ease;
82 + transition: background-color 0.2s ease, border-color 0.2s ease,
83 + box-shadow 0.2s ease, filter 0.2s ease;
84 /* backdrop-filter: blur(10px); */
85 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
86 position: relative;
@@ -87,8 +90,8 @@
90 }
91
92 .toast-item:hover {
90 - transform: translateY(-2px);
93 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
94 + filter: brightness(1.02);
95 }
96
97 /* Toast Type Styling */
@@ -216,7 +219,7 @@
219 }
220
221 .toast-item:hover {
219 - transform: none;
222 + filter: none;
223 }
224 }
225 </style>
webui/components/projects/project-selector.html
+16
@@ -57,6 +57,10 @@
57 </body>
58
59 <style>
60 + .project-dropdown-container {
61 + position: relative;
62 + }
63 +
64 .project-dropdown-button {
65 display: inline-flex;
66 align-items: center;
@@ -64,5 +68,17 @@
68 padding-left: 0.5em;
69 padding-right: 0.5em;
70 }
71 +
72 + /* Keep the menu flush with the trigger so the pointer does not cross a tiny hover gap. */
73 + .project-dropdown-container .dropdown-menu {
74 + margin-top: 0;
75 + top: calc(100% - 1px);
76 + }
77 +
78 + /* Override shared dropdown opacity here so project items do not look muted while hovering edges. */
79 + .project-dropdown-container .dropdown-item,
80 + .project-dropdown-container .dropdown-item .material-symbols-outlined {
81 + opacity: 1;
82 + }
83 </style>
84 </html>
webui/components/settings/tunnel/tunnel-section.html
+5 -4
@@ -476,14 +476,15 @@
476 color: #0078d4;
477 text-decoration: none;
478 font-size: 0.85rem;
479 - transition: all 0.2s ease;
479 + transition: background-color 0.2s ease, border-color 0.2s ease,
480 + box-shadow 0.2s ease;
481 word-break: break-all;
482 }
483
484 .microsoft-login-link:hover {
485 background-color: rgba(0, 120, 212, 0.2);
486 border-color: rgba(0, 120, 212, 0.5);
486 - transform: translateY(-1px);
487 + box-shadow: 0 4px 12px rgba(0, 120, 212, 0.15);
488 }
489
490 .microsoft-login-link .icon {
@@ -529,12 +530,12 @@
530 display: inline-flex;
531 align-items: center;
532 gap: 0.4rem;
532 - transition: all 0.2s ease;
533 + transition: background-color 0.2s ease, box-shadow 0.2s ease,
534 + filter 0.2s ease;
535 }
536
537 .btn-copy-code:hover {
538 background-color: #106ebe;
537 - transform: translateY(-1px);
539 box-shadow: 0 2px 8px rgba(0, 120, 212, 0.3);
540 }
541
webui/components/sidebar/top-section/quick-actions.html
+9 -6
@@ -140,7 +140,9 @@
140 padding: var(--spacing-xs);
141 flex: 1;
142 min-width: 0;
143 - transition: all var(--transition-speed), transform 0.1s ease-in-out;
143 + transition: background-color var(--transition-speed),
144 + border-color var(--transition-speed), box-shadow var(--transition-speed),
145 + opacity var(--transition-speed);
146 }
147
148 .config-button .material-symbols-outlined {
@@ -171,13 +173,14 @@
173 .config-button:hover {
174 background-color: var(--color-background-hover);
175 opacity: 1;
176 + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent);
177 z-index: 1;
178 position: relative;
179 }
180
181 .config-button:active {
182 opacity: 0.5;
180 - transform: scale(0.95);
183 + box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.06);
184 }
185
186 /* Dropdown specific positioning - fixed to escape overflow:hidden */
@@ -222,11 +225,11 @@
225 align-items: center;
226 opacity: 0.6;
227 cursor: pointer;
225 - -webkit-transition: all 0.1s ease-in-out, left var(--transition-speed) ease-in-out;
226 - transition: all 0.1s ease-in-out, left var(--transition-speed) ease-in-out;
228 + -webkit-transition: opacity 0.1s ease-in-out, color 0.1s ease-in-out, filter 0.1s ease-in-out, left var(--transition-speed) ease-in-out;
229 + transition: opacity 0.1s ease-in-out, color 0.1s ease-in-out, filter 0.1s ease-in-out, left var(--transition-speed) ease-in-out;
230 }
228 - #newChat:hover { opacity: 0.85; transform: scale(1.04); }
229 - #newChat:active { opacity: 0.5; transform: scale(0.98); }
231 + #newChat:hover { opacity: 0.85; filter: brightness(1.08); }
232 + #newChat:active { opacity: 0.5; filter: brightness(0.96); }
233 #newChat .material-symbols-outlined {
234 color: var(--color-text);
235 font-size: 24px;
webui/css/buttons.css
+22 -9
@@ -9,7 +9,11 @@
9 color: var(--color-text);
10 font-size: 0.875rem;
11 font-family: "Rubik", Arial, Helvetica, sans-serif;
12 - transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
12 + transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
13 + border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
14 + color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
15 + box-shadow 0.18s cubic-bezier(0.4, 0, 0.2, 1),
16 + filter 0.18s cubic-bezier(0.4, 0, 0.2, 1);
17 min-height: 2em; /* Standard height */
18 display: inline-flex;
19 align-items: center;
@@ -29,8 +33,9 @@
33 }
34
35 .button:hover {
32 - transform: scale(1.05);
36 filter: brightness(1.05);
37 + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent),
38 + 0 6px 14px rgba(0, 0, 0, 0.12);
39 }
40
41 .button.cancel:hover {
@@ -73,7 +78,8 @@
78 border: 1px solid var(--color-border);
79 border-radius: 4px;
80 cursor: pointer;
76 - transition: all 0.15s ease;
81 + transition: background-color 0.15s ease, border-color 0.15s ease,
82 + color 0.15s ease, box-shadow 0.15s ease;
83 }
84
85 .btn-action-header .material-symbols-outlined {
@@ -83,6 +89,7 @@
89 .btn-action-header:hover {
90 border-color: var(--color-primary);
91 color: var(--color-primary);
92 + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
93 }
94
95 .btn-action-header.confirm:hover {
@@ -116,13 +123,13 @@
123 border: 1px solid var(--color-border);
124 padding: 0.25rem;
125 color: var(--color-text);
119 - opacity: 0.7;
126 display: inline-flex;
127 align-items: center;
128 justify-content: center;
129 border-radius: 4px;
130 cursor: pointer;
125 - transition: all 0.15s ease;
131 + transition: background-color 0.15s ease, border-color 0.15s ease,
132 + color 0.15s ease, box-shadow 0.15s ease;
133 }
134
135 .btn-action .material-symbols-outlined {
@@ -130,11 +137,11 @@
137 }
138
139 .btn-action:hover {
133 - opacity: 1;
140 background: var(--color-panel);
141 border-color: var(--color-primary);
142 color: var(--color-primary);
137 - transform: translateY(-1px);
143 + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent),
144 + 0 4px 10px rgba(0, 0, 0, 0.1);
145 }
146
147 .btn-action.delete:hover {
@@ -153,13 +160,15 @@
160 align-items: center;
161 border-radius: 4px;
162 cursor: pointer;
156 - transition: all 0.15s ease;
163 + transition: background-color 0.15s ease, border-color 0.15s ease,
164 + color 0.15s ease, box-shadow 0.15s ease;
165 }
166
167 .btn-icon:hover:not(:disabled) {
168 background: var(--color-panel);
169 border-color: var(--color-primary);
170 color: var(--color-text);
171 + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
172 }
173
174 .btn-icon:disabled {
@@ -202,7 +211,10 @@
211 padding: 0.25rem;
212 width: 1.75rem;
213 height: 1.75rem;
205 - transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
214 + transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
215 + border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
216 + color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
217 + box-shadow 0.18s cubic-bezier(0.4, 0, 0.2, 1);
218 flex-shrink: 0;
219 }
220
@@ -214,6 +226,7 @@
226 .btn-icon-action:hover {
227 border-color: var(--color-primary);
228 background-color: var(--color-background-hover);
229 + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
230 }
231
232 .btn-icon-action:active {
webui/css/messages.css
+1 -1
@@ -545,7 +545,7 @@
545 }
546
547 .message-agent-response .msg-content .message-markdown-image-wrap img:hover {
548 - transform: translateY(-2px);
548 + filter: brightness(1.02);
549 }
550
551 .msg-content h1 {
webui/css/notification.css
+5 -3
@@ -12,7 +12,8 @@
12 border-radius: 6px;
13 color: var(--color-text);
14 cursor: pointer;
15 - transition: all 0.2s ease;
15 + transition: background-color 0.2s ease, border-color 0.2s ease,
16 + box-shadow 0.2s ease, opacity 0.2s ease;
17 font-size: 1rem;
18 width: 36px;
19 height: 36px;
@@ -25,7 +26,8 @@
26 .notification-toggle:hover {
27 background: rgba(255, 255, 255, 0.1);
28 border-color: rgba(255, 255, 255, 0.2);
28 - transform: scale(1.05);
29 + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08),
30 + 0 4px 12px rgba(0, 0, 0, 0.18);
31 }
32
33 .notification-toggle .notification-icon {
@@ -80,7 +82,7 @@
82 .notification-toggle.disabled:hover {
83 background: rgba(255, 255, 255, 0.05);
84 border-color: rgba(255, 255, 255, 0.1);
83 - transform: none;
85 + box-shadow: none;
86 }
87
88
webui/css/settings.css
+3 -2
@@ -180,13 +180,14 @@ nav ul li a {
180 border-radius: 8px;
181 padding: 1rem;
182 width: 100%;
183 - transition: all 0.2s ease-in-out;
183 + transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out,
184 + box-shadow 0.2s ease-in-out, opacity 0.2s ease-in-out;
185 }
186
187 nav ul li a:hover {
187 - transform: translateY(-2px);
188 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
189 background-color: var(--color-secondary);
190 + border-color: var(--color-primary);
191 }
192
193 nav ul li a img {
webui/css/speech.css
+3 -8
@@ -1,22 +1,17 @@
1 /* MIC BUTTON */
2 -#microphone-button {
3 -}
2
3 /* Only apply hover effects on devices that support hover */
4 @media (hover: hover) {
5 #microphone-button:hover {
6 background-color: #636363;
9 - transform: scale(1.05);
10 - -webkit-transform: scale(1.05);
11 - transform-origin: center;
7 + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08),
8 + 0 6px 14px rgba(0, 0, 0, 0.18);
9 }
10 }
11
12 #microphone-button:active {
13 background-color: #444444;
17 - transform: scale(1);
18 - -webkit-transform: scale(1);
19 - transform-origin: center;
14 + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
15 }
16
17 #microphone-button.recording {
webui/index.css
+13 -13
@@ -491,7 +491,8 @@ h4 {
491 justify-content: center;
492 overflow: hidden;
493 cursor: pointer;
494 - transition: all 0.3s ease;
494 + transition: border-color 0.3s ease, box-shadow 0.3s ease,
495 + filter 0.3s ease;
496 animation: fadeIn 0.3s ease;
497 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
498 }
@@ -499,7 +500,7 @@ h4 {
500 .preview-item:hover {
501 border-color: var(--color-primary);
502 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
502 - transform: translateY(-2px);
503 + filter: brightness(1.02);
504 }
505
506 .preview-item.image-preview img {
@@ -566,21 +567,19 @@ h4 {
567 display: flex;
568 align-items: center;
569 justify-content: center;
569 - transition: all 0.2s ease;
570 + transition: background-color 0.2s ease, box-shadow 0.2s ease,
571 + opacity 0.2s ease;
572 z-index: 2;
573 opacity: 0;
572 - transform: scale(0.8);
574 }
575
576 .device-pointer .preview-item:hover .remove-attachment,
577 .device-pointer .attachment-item:hover .remove-attachment {
578 opacity: 1;
578 - transform: scale(1);
579 }
580
581 .device-touch .remove-attachment {
582 opacity: 1 !important;
583 - transform: scale(1.25) !important;
583 /* font-size: 1.5em; */
584 top: 0.5em;
585 right: 0.5em;
@@ -588,15 +587,15 @@ h4 {
587
588 .remove-attachment:hover {
589 background-color: var(--color-accent-dark);
591 - transform: scale(1.1) !important;
590 + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18), 0 6px 14px rgba(0, 0, 0, 0.24);
591 }
592
593 .device-touch .remove-attachment:active {
595 - transform: scale(1.1) !important;
594 + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12) !important;
595 }
596
597 .remove-attachment:active {
599 - transform: scale(0.9) !important;
598 + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12) !important;
599 }
600
601 .image-error {
@@ -685,7 +684,8 @@ h4 {
684 justify-content: center;
685 overflow: hidden;
686 cursor: pointer;
688 - transition: all 0.3s ease;
687 + transition: border-color 0.3s ease, box-shadow 0.3s ease,
688 + filter 0.3s ease;
689 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
690 border-radius: var(--border-radius);
691 }
@@ -693,7 +693,7 @@ h4 {
693 .attachment-item:hover {
694 border-color: var(--color-primary);
695 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
696 - transform: translateY(-2px);
696 + filter: brightness(1.02);
697 }
698
699 .light-mode .attachment-item:hover {
@@ -844,11 +844,11 @@ h4 {
844
845 .remove-attachment:hover {
846 background-color: var(--color-accent);
847 - transform: scale(1.1);
847 + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18), 0 6px 14px rgba(0, 0, 0, 0.24);
848 }
849
850 .remove-attachment:active {
851 - transform: scale(0.9);
851 + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
852 }
853
854 /* Error handling */
webui/js/extensions.js
+36 -1
@@ -25,7 +25,7 @@ export const API_EXTENSION_EXCLUDED_ENDPOINTS = new Set([
25 "/api/load_webui_extensions",
26 ]);
27
28 -export function invalidateCache() {
28 +export function clearCache() {
29 cache.clear(JS_CACHE_AREA);
30 cache.clear(HTML_CACHE_AREA);
31 }
@@ -115,6 +115,41 @@ export async function loadHtmlExtensions(roots = [document.documentElement]) {
115 }
116 }
117
118 +/**
119 + * Reload and re-render all HTML extensions in the given DOM roots.
120 + *
121 + * @param {Element | Document | Array<Element | Document>} [roots]
122 + * @returns {Promise<void>}
123 + */
124 +export async function reloadHtmlExtensions(roots = [document.documentElement]) {
125 + try {
126 + /** @type {Array<Element | Document>} */
127 + const rootElements = Array.isArray(roots) ? roots : [roots];
128 +
129 + /** @type {Element[]} */
130 + const extensions = rootElements.flatMap((root) =>
131 + Array.from(root.querySelectorAll("x-extension")),
132 + );
133 +
134 + if (extensions.length === 0) return;
135 +
136 + await Promise.all(
137 + extensions.map(async (extension) => {
138 + const path = extension.getAttribute("id");
139 + if (!path) {
140 + console.error("x-extension missing id attribute:", extension);
141 + return;
142 + }
143 +
144 + extension.innerHTML = "";
145 + await importHtmlExtensions(path, /** @type {HTMLElement} */ (extension));
146 + }),
147 + );
148 + } catch (error) {
149 + console.error("Error reloading HTML extensions:", error);
150 + }
151 +}
152 +
153 // import all extensions for extension point via backend api
154 /**
155 * Import all HTML extensions for an extension point and inject them as `<x-component>` tags.