From 94c61aa19d2df8754b1c93bd495e8bebc35f19ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 01:18:38 +0100 Subject: [PATCH] feat(webchat): queue outgoing messages --- ui/src/styles/components.css | 58 +++++++++++++++++++++++ ui/src/ui/app-render.ts | 6 +++ ui/src/ui/app.ts | 88 +++++++++++++++++++++++++++++------ ui/src/ui/controllers/chat.ts | 6 +-- ui/src/ui/ui-types.ts | 6 +++ ui/src/ui/views/chat.ts | 40 ++++++++++++++-- 6 files changed, 182 insertions(+), 22 deletions(-) diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 07a48afe9..f4ef315ec 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -616,6 +616,64 @@ ); } +.chat-queue { + margin-top: 12px; + padding: 10px 12px; + border-radius: 16px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.18); + display: grid; + gap: 8px; +} + +:root[data-theme="light"] .chat-queue { + background: rgba(16, 24, 40, 0.04); +} + +.chat-queue__title { + font-family: var(--font-mono); + font-size: 12px; + color: var(--muted); +} + +.chat-queue__list { + display: grid; + gap: 8px; +} + +.chat-queue__item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + padding: 8px 10px; + border-radius: 12px; + border: 1px dashed var(--border); + background: rgba(0, 0, 0, 0.2); +} + +:root[data-theme="light"] .chat-queue__item { + background: rgba(16, 24, 40, 0.05); +} + +.chat-queue__text { + color: var(--chat-text); + font-size: 13px; + line-height: 1.4; + white-space: pre-wrap; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.chat-queue__remove { + align-self: start; + padding: 4px 10px; + font-size: 12px; + line-height: 1; +} + .chat-line { display: flex; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 417f267b3..c19820d4e 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -26,6 +26,7 @@ import type { StatusSummary, } from "./types"; import type { + ChatQueueItem, CronFormState, DiscordForm, IMessageForm, @@ -101,6 +102,7 @@ export type AppViewState = { chatStream: string | null; chatRunId: string | null; chatThinkingLevel: string | null; + chatQueue: ChatQueueItem[]; nodesLoading: boolean; nodes: Array>; configLoading: boolean; @@ -198,6 +200,7 @@ export type AppViewState = { handleWhatsAppLogout: () => Promise; handleTelegramSave: () => Promise; handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise; + removeQueuedMessage: (id: string) => void; resetToolStream: () => void; handleLogsScroll: (event: Event) => void; exportLogs: (lines: string[], label: string) => void; @@ -422,6 +425,7 @@ export function renderApp(state: AppViewState) { state.chatStream = null; state.chatStreamStartedAt = null; state.chatRunId = null; + state.chatQueue = []; state.resetToolStream(); state.resetChatScroll(); state.applySettings({ @@ -439,6 +443,7 @@ export function renderApp(state: AppViewState) { stream: state.chatStream, streamStartedAt: state.chatStreamStartedAt, draft: state.chatMessage, + queue: state.chatQueue, connected: state.connected, canSend: state.connected, disabledReason: chatDisabledReason, @@ -453,6 +458,7 @@ export function renderApp(state: AppViewState) { }, onDraftChange: (next) => (state.chatMessage = next), onSend: () => state.handleSendChat(), + onQueueRemove: (id) => state.removeQueuedMessage(id), onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), }) diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index f839f92f6..6e959494e 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -18,6 +18,7 @@ import { type ThemeMode, } from "./theme"; import { truncateText } from "./format"; +import { generateUUID } from "./uuid"; import { startThemeTransition, type ThemeTransitionContext, @@ -40,6 +41,7 @@ import type { import { defaultDiscordActions, defaultSlackActions, + type ChatQueueItem, type CronFormState, type DiscordForm, type IMessageForm, @@ -49,7 +51,7 @@ import { } from "./ui-types"; import { loadChatHistory, - sendChat, + sendChatMessage, handleChatEvent, type ChatEventPayload, } from "./controllers/chat"; @@ -214,6 +216,7 @@ export class ClawdbotApp extends LitElement { @state() chatStreamStartedAt: number | null = null; @state() chatRunId: string | null = null; @state() chatThinkingLevel: string | null = null; + @state() chatQueue: ChatQueueItem[] = []; @state() toolOutputExpanded = new Set(); @state() nodesLoading = false; @@ -761,6 +764,7 @@ export class ClawdbotApp extends LitElement { const state = handleChatEvent(this, payload); if (state === "final" || state === "error" || state === "aborted") { this.resetToolStream(); + void this.flushChatQueue(); } if (state === "final") void loadChatHistory(this); return; @@ -1003,19 +1007,32 @@ export class ClawdbotApp extends LitElement { async loadCron() { await Promise.all([loadCronStatus(this), loadCronJobs(this)]); } - async handleSendChat( - messageOverride?: string, - opts?: { restoreDraft?: boolean }, + + private isChatBusy() { + return this.chatSending || Boolean(this.chatRunId); + } + + private enqueueChatMessage(text: string) { + const trimmed = text.trim(); + if (!trimmed) return; + this.chatQueue = [ + ...this.chatQueue, + { + id: generateUUID(), + text: trimmed, + createdAt: Date.now(), + }, + ]; + } + + private async sendChatMessageNow( + message: string, + opts?: { previousDraft?: string; restoreDraft?: boolean }, ) { - if (!this.connected) return; - const previousDraft = this.chatMessage; - if (messageOverride != null) { - this.chatMessage = messageOverride; - } this.resetToolStream(); - const ok = await sendChat(this); - if (!ok && messageOverride != null) { - this.chatMessage = previousDraft; + const ok = await sendChatMessage(this, message); + if (!ok && opts?.previousDraft != null) { + this.chatMessage = opts.previousDraft; } if (ok) { this.setLastActiveSessionKey(this.sessionKey); @@ -1028,10 +1045,53 @@ export class ClawdbotApp extends LitElement { this.resetToolStream(); void loadChatHistory(this); } - if (ok && messageOverride != null && opts?.restoreDraft && previousDraft.trim()) { - this.chatMessage = previousDraft; + if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) { + this.chatMessage = opts.previousDraft; } this.scheduleChatScroll(); + if (ok && !this.chatRunId) { + void this.flushChatQueue(); + } + return ok; + } + + private async flushChatQueue() { + if (!this.connected || this.isChatBusy()) return; + const [next, ...rest] = this.chatQueue; + if (!next) return; + this.chatQueue = rest; + const ok = await this.sendChatMessageNow(next.text); + if (!ok) { + this.chatQueue = [next, ...this.chatQueue]; + } + } + + removeQueuedMessage(id: string) { + this.chatQueue = this.chatQueue.filter((item) => item.id !== id); + } + + async handleSendChat( + messageOverride?: string, + opts?: { restoreDraft?: boolean }, + ) { + if (!this.connected) return; + const previousDraft = this.chatMessage; + const message = (messageOverride ?? this.chatMessage).trim(); + if (!message) return; + + if (messageOverride == null) { + this.chatMessage = ""; + } + + if (this.isChatBusy()) { + this.enqueueChatMessage(message); + return; + } + + await this.sendChatMessageNow(message, { + previousDraft: messageOverride == null ? previousDraft : undefined, + restoreDraft: Boolean(messageOverride && opts?.restoreDraft), + }); } async handleWhatsAppStart(force: boolean) { diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 6ad248620..3d352fac0 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -42,9 +42,9 @@ export async function loadChatHistory(state: ChatState) { } } -export async function sendChat(state: ChatState): Promise { +export async function sendChatMessage(state: ChatState, message: string): Promise { if (!state.client || !state.connected) return false; - const msg = state.chatMessage.trim(); + const msg = message.trim(); if (!msg) return false; const now = Date.now(); @@ -58,7 +58,6 @@ export async function sendChat(state: ChatState): Promise { ]; state.chatSending = true; - state.chatMessage = ""; state.lastError = null; const runId = generateUUID(); state.chatRunId = runId; @@ -77,7 +76,6 @@ export async function sendChat(state: ChatState): Promise { state.chatRunId = null; state.chatStream = null; state.chatStreamStartedAt = null; - state.chatMessage = msg; state.lastError = error; state.chatMessages = [ ...state.chatMessages, diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index e78c584cf..7760ea40c 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -8,6 +8,12 @@ export type TelegramForm = { webhookPath: string; }; +export type ChatQueueItem = { + id: string; + text: string; + createdAt: number; +}; + export type DiscordForm = { enabled: boolean; token: string; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index bec69a724..213576f08 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1,9 +1,11 @@ import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; + import { toSanitizedMarkdownHtml } from "../markdown"; import { formatToolDetail, resolveToolDisplay } from "../tool-display"; import type { SessionsListResult } from "../types"; +import type { ChatQueueItem } from "../ui-types"; export type ChatProps = { sessionKey: string; @@ -16,6 +18,7 @@ export type ChatProps = { stream: string | null; streamStartedAt: number | null; draft: string; + queue: ChatQueueItem[]; connected: boolean; canSend: boolean; disabledReason: string | null; @@ -26,14 +29,16 @@ export type ChatProps = { onRefresh: () => void; onDraftChange: (next: string) => void; onSend: () => void; + onQueueRemove: (id: string) => void; onNewSession: () => void; }; export function renderChat(props: ChatProps) { - const canCompose = props.connected && !props.sending; + const canCompose = props.connected; + const isBusy = props.sending || Boolean(props.stream); const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions); const composePlaceholder = props.connected - ? "Message (Shift+↩ for line breaks)" + ? "Message (↩ to send, Shift+↩ for line breaks)" : "Connect to the gateway to start chatting…"; return html` @@ -106,6 +111,31 @@ export function renderChat(props: ChatProps) { )} + ${props.queue.length + ? html` +
+
Queued (${props.queue.length})
+
+ ${props.queue.map( + (item) => html` +
+
${item.text}
+ +
+ `, + )} +
+
+ ` + : nothing} +