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);