feat(webchat): queue outgoing messages

This commit is contained in:
Peter Steinberger
2026-01-09 01:18:38 +01:00
parent e84cafec8a
commit 94c61aa19d
6 changed files with 182 additions and 22 deletions

View File

@@ -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 { .chat-line {
display: flex; display: flex;
} }

View File

@@ -26,6 +26,7 @@ import type {
StatusSummary, StatusSummary,
} from "./types"; } from "./types";
import type { import type {
ChatQueueItem,
CronFormState, CronFormState,
DiscordForm, DiscordForm,
IMessageForm, IMessageForm,
@@ -101,6 +102,7 @@ export type AppViewState = {
chatStream: string | null; chatStream: string | null;
chatRunId: string | null; chatRunId: string | null;
chatThinkingLevel: string | null; chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];
nodesLoading: boolean; nodesLoading: boolean;
nodes: Array<Record<string, unknown>>; nodes: Array<Record<string, unknown>>;
configLoading: boolean; configLoading: boolean;
@@ -198,6 +200,7 @@ export type AppViewState = {
handleWhatsAppLogout: () => Promise<void>; handleWhatsAppLogout: () => Promise<void>;
handleTelegramSave: () => Promise<void>; handleTelegramSave: () => Promise<void>;
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>; handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
removeQueuedMessage: (id: string) => void;
resetToolStream: () => void; resetToolStream: () => void;
handleLogsScroll: (event: Event) => void; handleLogsScroll: (event: Event) => void;
exportLogs: (lines: string[], label: string) => void; exportLogs: (lines: string[], label: string) => void;
@@ -422,6 +425,7 @@ export function renderApp(state: AppViewState) {
state.chatStream = null; state.chatStream = null;
state.chatStreamStartedAt = null; state.chatStreamStartedAt = null;
state.chatRunId = null; state.chatRunId = null;
state.chatQueue = [];
state.resetToolStream(); state.resetToolStream();
state.resetChatScroll(); state.resetChatScroll();
state.applySettings({ state.applySettings({
@@ -439,6 +443,7 @@ export function renderApp(state: AppViewState) {
stream: state.chatStream, stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt, streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage, draft: state.chatMessage,
queue: state.chatQueue,
connected: state.connected, connected: state.connected,
canSend: state.connected, canSend: state.connected,
disabledReason: chatDisabledReason, disabledReason: chatDisabledReason,
@@ -453,6 +458,7 @@ export function renderApp(state: AppViewState) {
}, },
onDraftChange: (next) => (state.chatMessage = next), onDraftChange: (next) => (state.chatMessage = next),
onSend: () => state.handleSendChat(), onSend: () => state.handleSendChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () => onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }), state.handleSendChat("/new", { restoreDraft: true }),
}) })

View File

@@ -18,6 +18,7 @@ import {
type ThemeMode, type ThemeMode,
} from "./theme"; } from "./theme";
import { truncateText } from "./format"; import { truncateText } from "./format";
import { generateUUID } from "./uuid";
import { import {
startThemeTransition, startThemeTransition,
type ThemeTransitionContext, type ThemeTransitionContext,
@@ -40,6 +41,7 @@ import type {
import { import {
defaultDiscordActions, defaultDiscordActions,
defaultSlackActions, defaultSlackActions,
type ChatQueueItem,
type CronFormState, type CronFormState,
type DiscordForm, type DiscordForm,
type IMessageForm, type IMessageForm,
@@ -49,7 +51,7 @@ import {
} from "./ui-types"; } from "./ui-types";
import { import {
loadChatHistory, loadChatHistory,
sendChat, sendChatMessage,
handleChatEvent, handleChatEvent,
type ChatEventPayload, type ChatEventPayload,
} from "./controllers/chat"; } from "./controllers/chat";
@@ -214,6 +216,7 @@ export class ClawdbotApp extends LitElement {
@state() chatStreamStartedAt: number | null = null; @state() chatStreamStartedAt: number | null = null;
@state() chatRunId: string | null = null; @state() chatRunId: string | null = null;
@state() chatThinkingLevel: string | null = null; @state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];
@state() toolOutputExpanded = new Set<string>(); @state() toolOutputExpanded = new Set<string>();
@state() nodesLoading = false; @state() nodesLoading = false;
@@ -761,6 +764,7 @@ export class ClawdbotApp extends LitElement {
const state = handleChatEvent(this, payload); const state = handleChatEvent(this, payload);
if (state === "final" || state === "error" || state === "aborted") { if (state === "final" || state === "error" || state === "aborted") {
this.resetToolStream(); this.resetToolStream();
void this.flushChatQueue();
} }
if (state === "final") void loadChatHistory(this); if (state === "final") void loadChatHistory(this);
return; return;
@@ -1003,19 +1007,32 @@ export class ClawdbotApp extends LitElement {
async loadCron() { async loadCron() {
await Promise.all([loadCronStatus(this), loadCronJobs(this)]); await Promise.all([loadCronStatus(this), loadCronJobs(this)]);
} }
async handleSendChat(
messageOverride?: string, private isChatBusy() {
opts?: { restoreDraft?: boolean }, 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(); this.resetToolStream();
const ok = await sendChat(this); const ok = await sendChatMessage(this, message);
if (!ok && messageOverride != null) { if (!ok && opts?.previousDraft != null) {
this.chatMessage = previousDraft; this.chatMessage = opts.previousDraft;
} }
if (ok) { if (ok) {
this.setLastActiveSessionKey(this.sessionKey); this.setLastActiveSessionKey(this.sessionKey);
@@ -1028,10 +1045,53 @@ export class ClawdbotApp extends LitElement {
this.resetToolStream(); this.resetToolStream();
void loadChatHistory(this); void loadChatHistory(this);
} }
if (ok && messageOverride != null && opts?.restoreDraft && previousDraft.trim()) { if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
this.chatMessage = previousDraft; this.chatMessage = opts.previousDraft;
} }
this.scheduleChatScroll(); 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) { async handleWhatsAppStart(force: boolean) {

View File

@@ -42,9 +42,9 @@ export async function loadChatHistory(state: ChatState) {
} }
} }
export async function sendChat(state: ChatState): Promise<boolean> { export async function sendChatMessage(state: ChatState, message: string): Promise<boolean> {
if (!state.client || !state.connected) return false; if (!state.client || !state.connected) return false;
const msg = state.chatMessage.trim(); const msg = message.trim();
if (!msg) return false; if (!msg) return false;
const now = Date.now(); const now = Date.now();
@@ -58,7 +58,6 @@ export async function sendChat(state: ChatState): Promise<boolean> {
]; ];
state.chatSending = true; state.chatSending = true;
state.chatMessage = "";
state.lastError = null; state.lastError = null;
const runId = generateUUID(); const runId = generateUUID();
state.chatRunId = runId; state.chatRunId = runId;
@@ -77,7 +76,6 @@ export async function sendChat(state: ChatState): Promise<boolean> {
state.chatRunId = null; state.chatRunId = null;
state.chatStream = null; state.chatStream = null;
state.chatStreamStartedAt = null; state.chatStreamStartedAt = null;
state.chatMessage = msg;
state.lastError = error; state.lastError = error;
state.chatMessages = [ state.chatMessages = [
...state.chatMessages, ...state.chatMessages,

View File

@@ -8,6 +8,12 @@ export type TelegramForm = {
webhookPath: string; webhookPath: string;
}; };
export type ChatQueueItem = {
id: string;
text: string;
createdAt: number;
};
export type DiscordForm = { export type DiscordForm = {
enabled: boolean; enabled: boolean;
token: string; token: string;

View File

@@ -1,9 +1,11 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js"; import { repeat } from "lit/directives/repeat.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { toSanitizedMarkdownHtml } from "../markdown"; import { toSanitizedMarkdownHtml } from "../markdown";
import { formatToolDetail, resolveToolDisplay } from "../tool-display"; import { formatToolDetail, resolveToolDisplay } from "../tool-display";
import type { SessionsListResult } from "../types"; import type { SessionsListResult } from "../types";
import type { ChatQueueItem } from "../ui-types";
export type ChatProps = { export type ChatProps = {
sessionKey: string; sessionKey: string;
@@ -16,6 +18,7 @@ export type ChatProps = {
stream: string | null; stream: string | null;
streamStartedAt: number | null; streamStartedAt: number | null;
draft: string; draft: string;
queue: ChatQueueItem[];
connected: boolean; connected: boolean;
canSend: boolean; canSend: boolean;
disabledReason: string | null; disabledReason: string | null;
@@ -26,14 +29,16 @@ export type ChatProps = {
onRefresh: () => void; onRefresh: () => void;
onDraftChange: (next: string) => void; onDraftChange: (next: string) => void;
onSend: () => void; onSend: () => void;
onQueueRemove: (id: string) => void;
onNewSession: () => void; onNewSession: () => void;
}; };
export function renderChat(props: ChatProps) { 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 sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions);
const composePlaceholder = props.connected const composePlaceholder = props.connected
? "Message (Shift+↩ for line breaks)" ? "Message (↩ to send, Shift+↩ for line breaks)"
: "Connect to the gateway to start chatting…"; : "Connect to the gateway to start chatting…";
return html` return html`
@@ -106,6 +111,31 @@ export function renderChat(props: ChatProps) {
)} )}
</div> </div>
${props.queue.length
? html`
<div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__list">
${props.queue.map(
(item) => html`
<div class="chat-queue__item">
<div class="chat-queue__text">${item.text}</div>
<button
class="btn chat-queue__remove"
type="button"
aria-label="Remove queued message"
@click=${() => props.onQueueRemove(item.id)}
>
</button>
</div>
`,
)}
</div>
</div>
`
: nothing}
<div class="chat-compose"> <div class="chat-compose">
<label class="field chat-compose__field"> <label class="field chat-compose__field">
<span>Message</span> <span>Message</span>
@@ -114,7 +144,9 @@ export function renderChat(props: ChatProps) {
?disabled=${!props.connected} ?disabled=${!props.connected}
@keydown=${(e: KeyboardEvent) => { @keydown=${(e: KeyboardEvent) => {
if (e.key !== "Enter") return; if (e.key !== "Enter") return;
if (e.isComposing || e.keyCode === 229) return;
if (e.shiftKey) return; // Allow Shift+Enter for line breaks if (e.shiftKey) return; // Allow Shift+Enter for line breaks
if (!props.connected) return;
e.preventDefault(); e.preventDefault();
if (canCompose) props.onSend(); if (canCompose) props.onSend();
}} }}
@@ -132,10 +164,10 @@ export function renderChat(props: ChatProps) {
</button> </button>
<button <button
class="btn primary" class="btn primary"
?disabled=${!props.connected || props.sending} ?disabled=${!props.connected}
@click=${props.onSend} @click=${props.onSend}
> >
${props.sending ? "Sending…" : "Send"} ${isBusy ? "Queue" : "Send"}
</button> </button>
</div> </div>
</div> </div>