Queued Messages
keyboardstaff committed
Jan 29, 2026 at 21:22 UTC
a8595e15759ab52f76fcf12624c8abd8d0c7edac
14 files changed
+659
-32
python/api/message.py
+3
-27
@@ -1,11 +1,10 @@
1
from agent import AgentContext, UserMessage
2
from python.helpers.api import ApiHandler, Request, Response
3
4
-from python.helpers import files, extension
4
+from python.helpers import files, extension, message_queue as mq
5
import os
6
from werkzeug.utils import secure_filename
7
from python.helpers.defer import DeferredTask
8
-from python.helpers.print_style import PrintStyle
8
9
10
class Message(ApiHandler):
@@ -64,30 +63,7 @@ class Message(ApiHandler):
63
# Store attachments in agent data
64
# context.agent0.set_data("attachments", attachment_paths)
65
67
- # Prepare attachment filenames for logging
68
- attachment_filenames = (
69
- [os.path.basename(path) for path in attachment_paths]
70
- if attachment_paths
71
- else []
72
- )
73
-
74
- # Print to console and log
75
- PrintStyle(
76
- background_color="#6C3483", font_color="white", bold=True, padding=True
77
- ).print(f"User message:")
78
- PrintStyle(font_color="white", padding=False).print(f"> {message}")
79
- if attachment_filenames:
80
- PrintStyle(font_color="white", padding=False).print("Attachments:")
81
- for filename in attachment_filenames:
82
- PrintStyle(font_color="white", padding=False).print(f"- {filename}")
83
-
84
- # Log the message with message_id and attachments
85
- context.log.log(
86
- type="user",
87
- heading="",
88
- content=message,
89
- kvps={"attachments": attachment_filenames},
90
- id=message_id,
91
- )
66
+ # Log to console and UI using helper function
67
+ mq.log_user_message(context, message, attachment_paths, message_id)
68
69
return context.communicate(UserMessage(message, attachment_paths)), context
python/api/message_queue_add.py
new
+21
@@ -0,0 +1,21 @@
1
+from python.helpers.api import ApiHandler, Request, Response
2
+from python.helpers import message_queue as mq
3
+from agent import AgentContext
4
+
5
+
6
+class MessageQueueAdd(ApiHandler):
7
+ """Add a message to the queue."""
8
+
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ context = AgentContext.get(input.get("context", ""))
11
+ if not context:
12
+ return Response("Context not found", status=404)
13
+
14
+ text = input.get("text", "").strip()
15
+ attachments = input.get("attachments", []) # filenames from /upload API
16
+
17
+ if not text and not attachments:
18
+ return Response("Empty message", status=400)
19
+
20
+ item = mq.add(context, text, attachments)
21
+ return {"ok": True, "item_id": item["id"], "queue_length": len(mq.get_queue(context))}
python/api/message_queue_remove.py
new
+16
@@ -0,0 +1,16 @@
1
+from python.helpers.api import ApiHandler, Request, Response
2
+from python.helpers import message_queue as mq
3
+from agent import AgentContext
4
+
5
+
6
+class MessageQueueRemove(ApiHandler):
7
+ """Remove message(s) from queue."""
8
+
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ context = AgentContext.get(input.get("context", ""))
11
+ if not context:
12
+ return Response("Context not found", status=404)
13
+
14
+ item_id = input.get("item_id") # None means clear all
15
+ remaining = mq.remove(context, item_id)
16
+ return {"ok": True, "remaining": remaining}
python/api/message_queue_send.py
new
+30
@@ -0,0 +1,30 @@
1
+from python.helpers.api import ApiHandler, Request, Response
2
+from python.helpers import message_queue as mq
3
+from agent import AgentContext
4
+
5
+
6
+class MessageQueueSend(ApiHandler):
7
+ """Send queued message(s) immediately."""
8
+
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ context = AgentContext.get(input.get("context", ""))
11
+ if not context:
12
+ return Response("Context not found", status=404)
13
+
14
+ if not mq.has_queue(context):
15
+ return {"ok": True, "message": "Queue empty"}
16
+
17
+ item_id = input.get("item_id")
18
+ send_all = input.get("send_all", False)
19
+
20
+ if send_all:
21
+ count = mq.send_all_aggregated(context)
22
+ return {"ok": True, "sent_count": count}
23
+
24
+ # Send single item
25
+ item = mq.pop_item(context, item_id) if item_id else mq.pop_first(context)
26
+ if not item:
27
+ return Response("Item not found", status=404)
28
+
29
+ mq.send_message(context, item)
30
+ return {"ok": True, "sent_item_id": item["id"]}
python/api/poll.py
+1
@@ -122,4 +122,5 @@ class Poll(ApiHandler):
122
"notifications": notifications,
123
"notifications_guid": notification_manager.guid,
124
"notifications_version": len(notification_manager.updates),
125
+ "message_queue": context.output_data.get("message_queue", []) if context else [],
126
}
python/api/upload.py
+1
-1
@@ -14,7 +14,7 @@ class UploadFile(ApiHandler):
14
for file in file_list:
15
if file and self.allowed_file(file.filename): # Check file type
16
filename = secure_filename(file.filename) # type: ignore
17
- file.save(files.get_abs_path("tmp/upload", filename))
17
+ file.save(files.get_abs_path("tmp/uploads", filename))
18
saved_filenames.append(filename)
19
20
return {"filenames": saved_filenames} # Return saved filenames
python/extensions/monologue_end/_95_process_queue.py
new
+33
@@ -0,0 +1,33 @@
1
+import asyncio
2
+from python.helpers.extension import Extension
3
+from python.helpers import message_queue as mq
4
+from agent import LoopData
5
+
6
+
7
+class ProcessQueue(Extension):
8
+ """Process queued messages after monologue ends."""
9
+
10
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
11
+ # Only process for agent0 (main agent)
12
+ if self.agent.number != 0:
13
+ return
14
+
15
+ context = self.agent.context
16
+
17
+ # Check if there are queued messages
18
+ if mq.has_queue(context):
19
+ # Schedule delayed task to send next queued message
20
+ # This allows current monologue to fully complete first
21
+ asyncio.create_task(self._delayed_send(context))
22
+
23
+ async def _delayed_send(self, context):
24
+ """Wait for task to complete, then send next queued message."""
25
+ # Small delay to ensure monologue fully completes
26
+ await asyncio.sleep(0.1)
27
+
28
+ # Wait for current task to finish
29
+ while context.task and context.task.is_alive():
30
+ await asyncio.sleep(0.1)
31
+
32
+ # Send next queued message
33
+ mq.send_next(context)
python/helpers/message_queue.py
new
+182
@@ -0,0 +1,182 @@
1
+import os
2
+import uuid
3
+from typing import TYPE_CHECKING
4
+
5
+if TYPE_CHECKING:
6
+ from agent import AgentContext
7
+
8
+from python.helpers.print_style import PrintStyle
9
+
10
+QUEUE_KEY = "message_queue"
11
+QUEUE_SEQ_KEY = "message_queue_seq"
12
+UPLOAD_FOLDER = "/a0/tmp/uploads"
13
+
14
+
15
+def get_queue(context: "AgentContext") -> list:
16
+ """Get current queue from context.data."""
17
+ return context.get_data(QUEUE_KEY) or []
18
+
19
+
20
+def _get_next_seq(context: "AgentContext") -> int:
21
+ """Get next sequence number."""
22
+ seq = context.get_data(QUEUE_SEQ_KEY) or 0
23
+ seq += 1
24
+ context.set_data(QUEUE_SEQ_KEY, seq)
25
+ return seq
26
+
27
+
28
+def _sync_output(context: "AgentContext"):
29
+ """Sync queue to output_data for frontend polling."""
30
+ queue = get_queue(context)
31
+ # Truncate text for frontend display
32
+ truncated = []
33
+ for item in queue:
34
+ truncated.append({
35
+ "id": item["id"],
36
+ "seq": item.get("seq", 0),
37
+ "text": item["text"][:100] + "..." if len(item["text"]) > 100 else item["text"],
38
+ "attachments": [a.split("/")[-1] for a in item.get("attachments", [])],
39
+ "attachment_count": len(item.get("attachments", [])),
40
+ })
41
+ context.set_output_data(QUEUE_KEY, truncated)
42
+
43
+
44
+def add(context: "AgentContext", text: str, attachments: list[str] | None = None) -> dict:
45
+ """Add message to queue. Attachments should be filenames, will be converted to full paths."""
46
+ queue = get_queue(context)
47
+
48
+ # Convert filenames to full paths
49
+ full_paths = []
50
+ for att in (attachments or []):
51
+ if att.startswith("/"):
52
+ full_paths.append(att)
53
+ else:
54
+ full_paths.append(f"{UPLOAD_FOLDER}/{att}")
55
+
56
+ item = {
57
+ "id": str(uuid.uuid4())[:8],
58
+ "seq": _get_next_seq(context),
59
+ "text": text,
60
+ "attachments": full_paths,
61
+ }
62
+ queue.append(item)
63
+ context.set_data(QUEUE_KEY, queue)
64
+ _sync_output(context)
65
+ return item
66
+
67
+
68
+def remove(context: "AgentContext", item_id: str | None = None) -> int:
69
+ """Remove item(s). If item_id is None, clears all. Returns remaining count."""
70
+ if not item_id:
71
+ context.set_data(QUEUE_KEY, [])
72
+ context.set_output_data(QUEUE_KEY, [])
73
+ return 0
74
+ queue = [i for i in get_queue(context) if i["id"] != item_id]
75
+ context.set_data(QUEUE_KEY, queue)
76
+ _sync_output(context)
77
+ return len(queue)
78
+
79
+
80
+def pop_first(context: "AgentContext") -> dict | None:
81
+ """Remove and return first item."""
82
+ queue = get_queue(context)
83
+ if not queue:
84
+ return None
85
+ item = queue.pop(0)
86
+ context.set_data(QUEUE_KEY, queue)
87
+ _sync_output(context)
88
+ return item
89
+
90
+
91
+def pop_item(context: "AgentContext", item_id: str) -> dict | None:
92
+ """Remove and return specific item."""
93
+ queue = get_queue(context)
94
+ for i, item in enumerate(queue):
95
+ if item["id"] == item_id:
96
+ queue.pop(i)
97
+ context.set_data(QUEUE_KEY, queue)
98
+ _sync_output(context)
99
+ return item
100
+ return None
101
+
102
+
103
+def has_queue(context: "AgentContext") -> bool:
104
+ """Check if queue has items."""
105
+ return len(get_queue(context)) > 0
106
+
107
+
108
+def log_user_message(
109
+ context: "AgentContext",
110
+ message: str,
111
+ attachment_paths: list[str],
112
+ message_id: str | None = None,
113
+ source: str = "",
114
+):
115
+ """Log user message to console and UI. Used by message API and queue processing."""
116
+ # Prepare attachment filenames for logging
117
+ attachment_filenames = (
118
+ [os.path.basename(path) for path in attachment_paths]
119
+ if attachment_paths
120
+ else []
121
+ )
122
+
123
+ # Print to console
124
+ label = f"User message{source}:"
125
+ PrintStyle(
126
+ background_color="#6C3483", font_color="white", bold=True, padding=True
127
+ ).print(label)
128
+ PrintStyle(font_color="white", padding=False).print(f"> {message}")
129
+ if attachment_filenames:
130
+ PrintStyle(font_color="white", padding=False).print("Attachments:")
131
+ for filename in attachment_filenames:
132
+ PrintStyle(font_color="white", padding=False).print(f"- {filename}")
133
+
134
+ # Log to UI
135
+ context.log.log(
136
+ type="user",
137
+ heading="",
138
+ content=message,
139
+ kvps={"attachments": attachment_filenames},
140
+ id=message_id,
141
+ )
142
+
143
+
144
+def send_message(context: "AgentContext", item: dict, source: str = " (from queue)"):
145
+ """Send a single queued message (log + communicate)."""
146
+ from agent import UserMessage # Import here to avoid circular import
147
+
148
+ message = item.get("text", "")
149
+ attachments = item.get("attachments", [])
150
+ log_user_message(context, message, attachments, source=source)
151
+ context.communicate(UserMessage(message, attachments))
152
+
153
+
154
+def send_next(context: "AgentContext") -> bool:
155
+ """Send next queued message. Returns True if sent, False if queue empty."""
156
+ if not has_queue(context):
157
+ return False
158
+ item = pop_first(context)
159
+ if item:
160
+ send_message(context, item)
161
+ return True
162
+ return False
163
+
164
+
165
+def send_all_aggregated(context: "AgentContext") -> int:
166
+ """Aggregate and send all queued messages as one. Returns count of items sent."""
167
+ from agent import UserMessage # Import here to avoid circular import
168
+
169
+ if not has_queue(context):
170
+ return 0
171
+
172
+ items = []
173
+ while has_queue(context):
174
+ items.append(pop_first(context))
175
+
176
+ # Combine texts with separator
177
+ text = "\n\n---\n\n".join(i["text"] for i in items if i["text"])
178
+ attachments = [a for i in items for a in i.get("attachments", [])]
179
+
180
+ log_user_message(context, text, attachments, source=" (queued batch)")
181
+ context.communicate(UserMessage(text, attachments))
182
+ return len(items)
webui/components/chat/input/chat-bar-input.html
+30
-4
@@ -4,6 +4,7 @@
4
import { store as speechStore } from "/components/chat/speech/speech-store.js";
5
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6
import { store as fullScreenStore } from "/components/modals/full-screen-input/full-screen-store.js";
7
+ import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
8
</script>
9
</head>
10
<body>
@@ -33,10 +34,19 @@
34
<div id="chat-buttons-wrapper">
35
<!-- Send button -->
36
<button class="chat-button" id="send-button" aria-label="Send message"
36
- @click="$store.chatInput.sendMessage()">
37
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
38
- <path d="M25 20 L75 50 L25 80" fill="none" stroke="currentColor" stroke-width="15"></path>
39
- </svg>
37
+ @click="$store.chatInput.sendMessage()"
38
+ :class="{ 'send-queue': $store.messageQueue?.hasQueue }"
39
+ :title="$store.messageQueue?.hasQueue ? 'Send queued messages' : 'Send message'">
40
+ <!-- Normal send icon -->
41
+ <template x-if="!$store.messageQueue?.hasQueue">
42
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
43
+ <path d="M25 20 L75 50 L25 80" fill="none" stroke="currentColor" stroke-width="15"></path>
44
+ </svg>
45
+ </template>
46
+ <!-- Spinning loader when queue has messages -->
47
+ <template x-if="$store.messageQueue?.hasQueue">
48
+ <span class="send-queue-spinner"></span>
49
+ </template>
50
</button>
51
52
<!-- Microphone button -->
@@ -108,9 +118,25 @@
118
#chat-buttons-wrapper { gap: var(--spacing-xs); padding-left: var(--spacing-xs); line-height: 0.5rem; display: -webkit-flex; display: flex; }
119
.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; }
120
#send-button { background-color: #4248f1; }
121
+ #send-button.send-queue { background-color: #e67e22; }
122
#send-button:hover { -webkit-transform: scale(1.05); transform: scale(1.05); transform-origin: center; background-color: #353bc5; }
123
+ #send-button.send-queue:hover { background-color: #d35400; }
124
#send-button:active { -webkit-transform: scale(1); transform: scale(1); transform-origin: center; background-color: #2b309c; }
125
.chat-button svg { width: 1.5rem; height: 1.5rem; }
126
+
127
+ /* Queue spinner in send button */
128
+ .send-queue-spinner {
129
+ width: 18px;
130
+ height: 18px;
131
+ border: 3px solid rgba(255,255,255,0.3);
132
+ border-top-color: white;
133
+ border-radius: 50%;
134
+ animation: send-queue-spin 0.8s linear infinite;
135
+ }
136
+ @keyframes send-queue-spin {
137
+ to { transform: rotate(360deg); }
138
+ }
139
+
140
/* Microphone button */
141
.chat-button.mic-inactive svg { /* Add specific styles if needed */ }
142
/* Responsive tweaks */
webui/components/chat/input/chat-bar.html
+4
@@ -2,12 +2,16 @@
2
<head>
3
<script type="module">
4
import { store } from "/components/chat/input/input-store.js";
5
+ import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
6
</script>
7
</head>
8
<body>
9
<div id="input-section" x-data>
10
<template x-if="$store.chatInput">
11
<div style="width: 100%; display: contents;">
12
+ <!-- Message Queue section -->
13
+ <x-component path="chat/message-queue/message-queue.html"></x-component>
14
+
15
<!-- Attachment Preview section -->
16
<div>
17
<x-component path="/chat/attachments/inputPreview.html" />
webui/components/chat/message-queue/message-queue-store.js
new
+90
@@ -0,0 +1,90 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import * as api from "/js/api.js";
3
+
4
+const model = {
5
+ items: [],
6
+
7
+ get hasQueue() {
8
+ return this.items.length > 0;
9
+ },
10
+
11
+ get count() {
12
+ return this.items.length;
13
+ },
14
+
15
+ async addToQueue(text, attachments = []) {
16
+ const context = globalThis.getContext?.();
17
+ if (!context) return false;
18
+
19
+ try {
20
+ let filenames = [];
21
+ if (attachments.length > 0) {
22
+ const formData = new FormData();
23
+ for (const att of attachments) {
24
+ formData.append("file", att.file || att);
25
+ }
26
+ const resp = await api.fetchApi("/upload", { method: "POST", body: formData });
27
+ if (resp.ok) {
28
+ const result = await resp.json();
29
+ filenames = result.filenames || [];
30
+ }
31
+ }
32
+ const response = await api.callJsonApi("/message_queue_add", { context, text, attachments: filenames });
33
+ return response?.ok || false;
34
+ } catch (e) {
35
+ console.error("Failed to queue message:", e);
36
+ return false;
37
+ }
38
+ },
39
+
40
+ async removeItem(itemId) {
41
+ const context = globalThis.getContext?.();
42
+ if (!context) return;
43
+ try {
44
+ await api.callJsonApi("/message_queue_remove", { context, item_id: itemId });
45
+ } catch (e) {
46
+ console.error("Failed to remove from queue:", e);
47
+ }
48
+ },
49
+
50
+ async clearQueue() {
51
+ const context = globalThis.getContext?.();
52
+ if (!context) return;
53
+ try {
54
+ await api.callJsonApi("/message_queue_remove", { context });
55
+ } catch (e) {
56
+ console.error("Failed to clear queue:", e);
57
+ }
58
+ },
59
+
60
+ async sendItem(itemId) {
61
+ const context = globalThis.getContext?.();
62
+ if (!context) return;
63
+ try {
64
+ await api.callJsonApi("/message_queue_send", { context, item_id: itemId });
65
+ } catch (e) {
66
+ console.error("Failed to send queued message:", e);
67
+ }
68
+ },
69
+
70
+ async sendAll() {
71
+ const context = globalThis.getContext?.();
72
+ if (!context || !this.hasQueue) return;
73
+ try {
74
+ await api.callJsonApi("/message_queue_send", { context, send_all: true });
75
+ } catch (e) {
76
+ console.error("Failed to send all queued:", e);
77
+ }
78
+ },
79
+
80
+ updateFromPoll(queue) {
81
+ this.items = queue || [];
82
+ },
83
+
84
+ getAttachmentUrl(filename) {
85
+ return `/image_get?path=/a0/tmp/uploads/${encodeURIComponent(filename)}`;
86
+ },
87
+};
88
+
89
+const store = createStore("messageQueue", model);
90
+export { store };
webui/components/chat/message-queue/message-queue.html
new
+223
@@ -0,0 +1,223 @@
1
+<html>
2
+<head>
3
+ <script type="module">
4
+ import { store } from "/components/chat/message-queue/message-queue-store.js";
5
+ </script>
6
+</head>
7
+<body>
8
+ <div x-data>
9
+ <template x-if="$store.messageQueue?.hasQueue">
10
+ <div class="queue-preview">
11
+ <div class="queue-header">
12
+ <span class="queue-title">
13
+ <span class="queue-spinner"></span>
14
+ Queued Messages (<span x-text="$store.messageQueue.count"></span>)
15
+ </span>
16
+ <button class="queue-clear-btn" @click="$store.messageQueue.clearQueue()" title="Clear all">
17
+ <span class="material-symbols-outlined">delete_sweep</span>
18
+ </button>
19
+ </div>
20
+ <div class="queue-items">
21
+ <template x-for="(item, index) in $store.messageQueue.items" :key="item.id">
22
+ <div class="queue-item">
23
+ <div class="queue-item-content">
24
+ <span class="queue-item-seq" x-text="index + 1"></span>
25
+ <span class="queue-item-text" x-text="item.text || '(attachment only)'"></span>
26
+ <template x-if="item.attachments?.length > 0">
27
+ <div class="queue-attachments">
28
+ <template x-for="att in item.attachments" :key="att">
29
+ <img
30
+ :src="$store.messageQueue.getAttachmentUrl(att)"
31
+ :alt="att"
32
+ class="queue-attachment-thumb"
33
+ :title="att"
34
+ />
35
+ </template>
36
+ </div>
37
+ </template>
38
+ </div>
39
+ <div class="queue-item-actions">
40
+ <button class="queue-action-btn send" @click="$store.messageQueue.sendItem(item.id)" title="Send now">
41
+ <span class="material-symbols-outlined">send</span>
42
+ </button>
43
+ <button class="queue-action-btn delete" @click="$store.messageQueue.removeItem(item.id)" title="Remove">
44
+ <span class="material-symbols-outlined">close</span>
45
+ </button>
46
+ </div>
47
+ </div>
48
+ </template>
49
+ </div>
50
+ </div>
51
+ </template>
52
+ </div>
53
+
54
+ <style>
55
+ .queue-preview {
56
+ border: 1px solid var(--color-border);
57
+ border-radius: 8px;
58
+ padding: var(--spacing-sm);
59
+ margin-bottom: var(--spacing-xs);
60
+ }
61
+
62
+ .queue-header {
63
+ display: flex;
64
+ justify-content: space-between;
65
+ align-items: center;
66
+ margin-bottom: var(--spacing-xs);
67
+ }
68
+
69
+ .queue-title {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: var(--spacing-xs);
73
+ font-size: var(--font-size-xs);
74
+ color: var(--color-text-secondary);
75
+ }
76
+
77
+ .queue-spinner {
78
+ width: 12px;
79
+ height: 12px;
80
+ border: 2px solid var(--color-border);
81
+ border-top-color: var(--color-primary);
82
+ border-radius: 50%;
83
+ animation: queue-spin 1s linear infinite;
84
+ }
85
+
86
+ @keyframes queue-spin {
87
+ to { transform: rotate(360deg); }
88
+ }
89
+
90
+ .queue-clear-btn {
91
+ background: transparent;
92
+ border: none;
93
+ color: var(--color-text-secondary);
94
+ cursor: pointer;
95
+ padding: 2px;
96
+ border-radius: 4px;
97
+ display: flex;
98
+ align-items: center;
99
+ opacity: 0.6;
100
+ transition: all 0.1s ease-in-out;
101
+ }
102
+
103
+ .queue-clear-btn:hover {
104
+ opacity: 1;
105
+ transform: scale(1.1);
106
+ }
107
+
108
+ .queue-clear-btn:active {
109
+ opacity: 0.5;
110
+ transform: scale(0.95);
111
+ }
112
+
113
+ .queue-clear-btn .material-symbols-outlined {
114
+ font-size: var(--font-size-large);
115
+ }
116
+
117
+ .queue-items {
118
+ display: flex;
119
+ flex-direction: column;
120
+ gap: 2px;
121
+ max-height: 120px;
122
+ overflow-y: auto;
123
+ }
124
+
125
+ .queue-items::-webkit-scrollbar {
126
+ flex: 1;
127
+ min-height: 0;
128
+ overflow: scroll;
129
+ scroll-behavior: smooth;
130
+ max-height: 100%;
131
+ scrollbar-width: none;
132
+ -ms-overflow-style: none;
133
+ }
134
+
135
+ .queue-item {
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: space-between;
139
+ gap: var(--spacing-sm);
140
+ padding: 6px 8px;
141
+ border-radius: 4px;
142
+ cursor: default;
143
+ }
144
+
145
+ .queue-item:hover {
146
+ background-color: var(--color-background-hover);
147
+ }
148
+
149
+ .queue-item-content {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: var(--spacing-sm);
153
+ flex: 1;
154
+ min-width: 0;
155
+ }
156
+
157
+ .queue-item-seq {
158
+ display: inline-flex;
159
+ align-items: center;
160
+ justify-content: center;
161
+ width: 1rem;
162
+ height: 1rem;
163
+ font-size: 0.5rem;
164
+ color: var(--color-text-secondary);
165
+ background: var(--color-background);
166
+ border-radius: 50%;
167
+ }
168
+
169
+ .queue-item-text {
170
+ font-size: var(--font-size-small);
171
+ color: var(--color-text);
172
+ white-space: nowrap;
173
+ overflow: hidden;
174
+ text-overflow: ellipsis;
175
+ flex: 1;
176
+ min-width: 0;
177
+ }
178
+
179
+ .queue-attachments {
180
+ display: flex;
181
+ gap: 4px;
182
+ flex-shrink: 0;
183
+ }
184
+
185
+ .queue-attachment-thumb {
186
+ width: 20px;
187
+ height: 20px;
188
+ object-fit: contain;
189
+ border-radius: 3px;
190
+ border: 1px solid var(--color-border);
191
+ }
192
+
193
+ .queue-item-actions {
194
+ display: flex;
195
+ gap: 2px;
196
+ flex-shrink: 0;
197
+ opacity: 0;
198
+ visibility: hidden;
199
+ transition: opacity 0.15s;
200
+ }
201
+
202
+ .queue-item:hover .queue-item-actions {
203
+ opacity: 1;
204
+ visibility: visible;
205
+ }
206
+
207
+ .queue-action-btn {
208
+ background: transparent;
209
+ border: none;
210
+ color: var(--color-primary);
211
+ cursor: pointer;
212
+ padding: 4px;
213
+ border-radius: 4px;
214
+ display: flex;
215
+ align-items: center;
216
+ }
217
+
218
+ .queue-action-btn .material-symbols-outlined {
219
+ font-size: var(--font-size-smaller);
220
+ }
221
+ </style>
222
+</body>
223
+</html>
webui/components/chat/top-section/chat-top-store.js
+1
@@ -3,6 +3,7 @@ import { createStore } from "/js/AlpineStore.js";
3
// define the model object holding data and functions
4
const model = {
5
connected: false,
6
+ progressActive: false, // true when agent is working
7
};
8
9
// convert it to alpine store
webui/index.js
+24
@@ -11,6 +11,7 @@ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
11
import { store as tasksStore } from "/components/sidebar/tasks/tasks-store.js";
12
import { store as chatTopStore } from "/components/chat/top-section/chat-top-store.js";
13
import { store as _tooltipsStore } from "/components/tooltips/tooltip-store.js";
14
+import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
15
import { applyModeSteps } from "/components/messages/process-group/process-group-dom.js";
16
17
globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
@@ -46,7 +47,24 @@ export async function sendMessage() {
47
const attachmentsWithUrls = attachmentsStore.getAttachmentsForSending();
48
const hasAttachments = attachmentsWithUrls.length > 0;
49
50
+ // If empty input but has queued messages, send all queued
51
+ if (!message && !hasAttachments && messageQueueStore.hasQueue) {
52
+ await messageQueueStore.sendAll();
53
+ return;
54
+ }
55
+
56
if (message || hasAttachments) {
57
+ // Check if agent is busy - queue instead of sending
58
+ if (chatTopStore.progressActive) {
59
+ const success = await messageQueueStore.addToQueue(message, attachmentsWithUrls);
60
+ if (success) {
61
+ chatInputEl.value = "";
62
+ attachmentsStore.clearAttachments();
63
+ adjustTextareaHeight();
64
+ }
65
+ return;
66
+ }
67
+
68
// Sending a message is an explicit user intent to go to the bottom
69
forceScrollChatToBottom();
70
@@ -327,6 +345,12 @@ export async function poll() {
345
lastLogGuid = response.log_guid;
346
347
updateProgress(response.log_progress, response.log_progress_active);
348
+
349
+ // Update agent busy state for queue logic
350
+ chatTopStore.progressActive = response.log_progress_active;
351
+
352
+ // Update message queue from poll
353
+ messageQueueStore.updateFromPoll(response.message_queue);
354
355
// Update notifications from response
356
notificationStore.updateFromPoll(response);