Skip to content

Commit 94c61aa

Browse files
committed
feat(webchat): queue outgoing messages
1 parent e84cafe commit 94c61aa

6 files changed

Lines changed: 182 additions & 22 deletions

File tree

ui/src/styles/components.css

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,64 @@
616616
);
617617
}
618618

619+
.chat-queue {
620+
margin-top: 12px;
621+
padding: 10px 12px;
622+
border-radius: 16px;
623+
border: 1px solid var(--border);
624+
background: rgba(0, 0, 0, 0.18);
625+
display: grid;
626+
gap: 8px;
627+
}
628+
629+
:root[data-theme="light"] .chat-queue {
630+
background: rgba(16, 24, 40, 0.04);
631+
}
632+
633+
.chat-queue__title {
634+
font-family: var(--font-mono);
635+
font-size: 12px;
636+
color: var(--muted);
637+
}
638+
639+
.chat-queue__list {
640+
display: grid;
641+
gap: 8px;
642+
}
643+
644+
.chat-queue__item {
645+
display: grid;
646+
grid-template-columns: minmax(0, 1fr) auto;
647+
align-items: start;
648+
gap: 10px;
649+
padding: 8px 10px;
650+
border-radius: 12px;
651+
border: 1px dashed var(--border);
652+
background: rgba(0, 0, 0, 0.2);
653+
}
654+
655+
:root[data-theme="light"] .chat-queue__item {
656+
background: rgba(16, 24, 40, 0.05);
657+
}
658+
659+
.chat-queue__text {
660+
color: var(--chat-text);
661+
font-size: 13px;
662+
line-height: 1.4;
663+
white-space: pre-wrap;
664+
overflow: hidden;
665+
display: -webkit-box;
666+
-webkit-line-clamp: 3;
667+
-webkit-box-orient: vertical;
668+
}
669+
670+
.chat-queue__remove {
671+
align-self: start;
672+
padding: 4px 10px;
673+
font-size: 12px;
674+
line-height: 1;
675+
}
676+
619677
.chat-line {
620678
display: flex;
621679
}

ui/src/ui/app-render.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type {
2626
StatusSummary,
2727
} from "./types";
2828
import type {
29+
ChatQueueItem,
2930
CronFormState,
3031
DiscordForm,
3132
IMessageForm,
@@ -101,6 +102,7 @@ export type AppViewState = {
101102
chatStream: string | null;
102103
chatRunId: string | null;
103104
chatThinkingLevel: string | null;
105+
chatQueue: ChatQueueItem[];
104106
nodesLoading: boolean;
105107
nodes: Array<Record<string, unknown>>;
106108
configLoading: boolean;
@@ -198,6 +200,7 @@ export type AppViewState = {
198200
handleWhatsAppLogout: () => Promise<void>;
199201
handleTelegramSave: () => Promise<void>;
200202
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
203+
removeQueuedMessage: (id: string) => void;
201204
resetToolStream: () => void;
202205
handleLogsScroll: (event: Event) => void;
203206
exportLogs: (lines: string[], label: string) => void;
@@ -422,6 +425,7 @@ export function renderApp(state: AppViewState) {
422425
state.chatStream = null;
423426
state.chatStreamStartedAt = null;
424427
state.chatRunId = null;
428+
state.chatQueue = [];
425429
state.resetToolStream();
426430
state.resetChatScroll();
427431
state.applySettings({
@@ -439,6 +443,7 @@ export function renderApp(state: AppViewState) {
439443
stream: state.chatStream,
440444
streamStartedAt: state.chatStreamStartedAt,
441445
draft: state.chatMessage,
446+
queue: state.chatQueue,
442447
connected: state.connected,
443448
canSend: state.connected,
444449
disabledReason: chatDisabledReason,
@@ -453,6 +458,7 @@ export function renderApp(state: AppViewState) {
453458
},
454459
onDraftChange: (next) => (state.chatMessage = next),
455460
onSend: () => state.handleSendChat(),
461+
onQueueRemove: (id) => state.removeQueuedMessage(id),
456462
onNewSession: () =>
457463
state.handleSendChat("/new", { restoreDraft: true }),
458464
})

ui/src/ui/app.ts

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type ThemeMode,
1919
} from "./theme";
2020
import { truncateText } from "./format";
21+
import { generateUUID } from "./uuid";
2122
import {
2223
startThemeTransition,
2324
type ThemeTransitionContext,
@@ -40,6 +41,7 @@ import type {
4041
import {
4142
defaultDiscordActions,
4243
defaultSlackActions,
44+
type ChatQueueItem,
4345
type CronFormState,
4446
type DiscordForm,
4547
type IMessageForm,
@@ -49,7 +51,7 @@ import {
4951
} from "./ui-types";
5052
import {
5153
loadChatHistory,
52-
sendChat,
54+
sendChatMessage,
5355
handleChatEvent,
5456
type ChatEventPayload,
5557
} from "./controllers/chat";
@@ -214,6 +216,7 @@ export class ClawdbotApp extends LitElement {
214216
@state() chatStreamStartedAt: number | null = null;
215217
@state() chatRunId: string | null = null;
216218
@state() chatThinkingLevel: string | null = null;
219+
@state() chatQueue: ChatQueueItem[] = [];
217220
@state() toolOutputExpanded = new Set<string>();
218221

219222
@state() nodesLoading = false;
@@ -761,6 +764,7 @@ export class ClawdbotApp extends LitElement {
761764
const state = handleChatEvent(this, payload);
762765
if (state === "final" || state === "error" || state === "aborted") {
763766
this.resetToolStream();
767+
void this.flushChatQueue();
764768
}
765769
if (state === "final") void loadChatHistory(this);
766770
return;
@@ -1003,19 +1007,32 @@ export class ClawdbotApp extends LitElement {
10031007
async loadCron() {
10041008
await Promise.all([loadCronStatus(this), loadCronJobs(this)]);
10051009
}
1006-
async handleSendChat(
1007-
messageOverride?: string,
1008-
opts?: { restoreDraft?: boolean },
1010+
1011+
private isChatBusy() {
1012+
return this.chatSending || Boolean(this.chatRunId);
1013+
}
1014+
1015+
private enqueueChatMessage(text: string) {
1016+
const trimmed = text.trim();
1017+
if (!trimmed) return;
1018+
this.chatQueue = [
1019+
...this.chatQueue,
1020+
{
1021+
id: generateUUID(),
1022+
text: trimmed,
1023+
createdAt: Date.now(),
1024+
},
1025+
];
1026+
}
1027+
1028+
private async sendChatMessageNow(
1029+
message: string,
1030+
opts?: { previousDraft?: string; restoreDraft?: boolean },
10091031
) {
1010-
if (!this.connected) return;
1011-
const previousDraft = this.chatMessage;
1012-
if (messageOverride != null) {
1013-
this.chatMessage = messageOverride;
1014-
}
10151032
this.resetToolStream();
1016-
const ok = await sendChat(this);
1017-
if (!ok && messageOverride != null) {
1018-
this.chatMessage = previousDraft;
1033+
const ok = await sendChatMessage(this, message);
1034+
if (!ok && opts?.previousDraft != null) {
1035+
this.chatMessage = opts.previousDraft;
10191036
}
10201037
if (ok) {
10211038
this.setLastActiveSessionKey(this.sessionKey);
@@ -1028,10 +1045,53 @@ export class ClawdbotApp extends LitElement {
10281045
this.resetToolStream();
10291046
void loadChatHistory(this);
10301047
}
1031-
if (ok && messageOverride != null && opts?.restoreDraft && previousDraft.trim()) {
1032-
this.chatMessage = previousDraft;
1048+
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
1049+
this.chatMessage = opts.previousDraft;
10331050
}
10341051
this.scheduleChatScroll();
1052+
if (ok && !this.chatRunId) {
1053+
void this.flushChatQueue();
1054+
}
1055+
return ok;
1056+
}
1057+
1058+
private async flushChatQueue() {
1059+
if (!this.connected || this.isChatBusy()) return;
1060+
const [next, ...rest] = this.chatQueue;
1061+
if (!next) return;
1062+
this.chatQueue = rest;
1063+
const ok = await this.sendChatMessageNow(next.text);
1064+
if (!ok) {
1065+
this.chatQueue = [next, ...this.chatQueue];
1066+
}
1067+
}
1068+
1069+
removeQueuedMessage(id: string) {
1070+
this.chatQueue = this.chatQueue.filter((item) => item.id !== id);
1071+
}
1072+
1073+
async handleSendChat(
1074+
messageOverride?: string,
1075+
opts?: { restoreDraft?: boolean },
1076+
) {
1077+
if (!this.connected) return;
1078+
const previousDraft = this.chatMessage;
1079+
const message = (messageOverride ?? this.chatMessage).trim();
1080+
if (!message) return;
1081+
1082+
if (messageOverride == null) {
1083+
this.chatMessage = "";
1084+
}
1085+
1086+
if (this.isChatBusy()) {
1087+
this.enqueueChatMessage(message);
1088+
return;
1089+
}
1090+
1091+
await this.sendChatMessageNow(message, {
1092+
previousDraft: messageOverride == null ? previousDraft : undefined,
1093+
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
1094+
});
10351095
}
10361096

10371097
async handleWhatsAppStart(force: boolean) {

ui/src/ui/controllers/chat.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ export async function loadChatHistory(state: ChatState) {
4242
}
4343
}
4444

45-
export async function sendChat(state: ChatState): Promise<boolean> {
45+
export async function sendChatMessage(state: ChatState, message: string): Promise<boolean> {
4646
if (!state.client || !state.connected) return false;
47-
const msg = state.chatMessage.trim();
47+
const msg = message.trim();
4848
if (!msg) return false;
4949

5050
const now = Date.now();
@@ -58,7 +58,6 @@ export async function sendChat(state: ChatState): Promise<boolean> {
5858
];
5959

6060
state.chatSending = true;
61-
state.chatMessage = "";
6261
state.lastError = null;
6362
const runId = generateUUID();
6463
state.chatRunId = runId;
@@ -77,7 +76,6 @@ export async function sendChat(state: ChatState): Promise<boolean> {
7776
state.chatRunId = null;
7877
state.chatStream = null;
7978
state.chatStreamStartedAt = null;
80-
state.chatMessage = msg;
8179
state.lastError = error;
8280
state.chatMessages = [
8381
...state.chatMessages,

ui/src/ui/ui-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ export type TelegramForm = {
88
webhookPath: string;
99
};
1010

11+
export type ChatQueueItem = {
12+
id: string;
13+
text: string;
14+
createdAt: number;
15+
};
16+
1117
export type DiscordForm = {
1218
enabled: boolean;
1319
token: string;

ui/src/ui/views/chat.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { html, nothing } from "lit";
22
import { repeat } from "lit/directives/repeat.js";
33
import { unsafeHTML } from "lit/directives/unsafe-html.js";
4+
45
import { toSanitizedMarkdownHtml } from "../markdown";
56
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
67
import type { SessionsListResult } from "../types";
8+
import type { ChatQueueItem } from "../ui-types";
79

810
export type ChatProps = {
911
sessionKey: string;
@@ -16,6 +18,7 @@ export type ChatProps = {
1618
stream: string | null;
1719
streamStartedAt: number | null;
1820
draft: string;
21+
queue: ChatQueueItem[];
1922
connected: boolean;
2023
canSend: boolean;
2124
disabledReason: string | null;
@@ -26,14 +29,16 @@ export type ChatProps = {
2629
onRefresh: () => void;
2730
onDraftChange: (next: string) => void;
2831
onSend: () => void;
32+
onQueueRemove: (id: string) => void;
2933
onNewSession: () => void;
3034
};
3135

3236
export function renderChat(props: ChatProps) {
33-
const canCompose = props.connected && !props.sending;
37+
const canCompose = props.connected;
38+
const isBusy = props.sending || Boolean(props.stream);
3439
const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions);
3540
const composePlaceholder = props.connected
36-
? "Message (Shift+↩ for line breaks)"
41+
? "Message (↩ to send, Shift+↩ for line breaks)"
3742
: "Connect to the gateway to start chatting…";
3843

3944
return html`
@@ -106,6 +111,31 @@ export function renderChat(props: ChatProps) {
106111
)}
107112
</div>
108113
114+
${props.queue.length
115+
? html`
116+
<div class="chat-queue" role="status" aria-live="polite">
117+
<div class="chat-queue__title">Queued (${props.queue.length})</div>
118+
<div class="chat-queue__list">
119+
${props.queue.map(
120+
(item) => html`
121+
<div class="chat-queue__item">
122+
<div class="chat-queue__text">${item.text}</div>
123+
<button
124+
class="btn chat-queue__remove"
125+
type="button"
126+
aria-label="Remove queued message"
127+
@click=${() => props.onQueueRemove(item.id)}
128+
>
129+
130+
</button>
131+
</div>
132+
`,
133+
)}
134+
</div>
135+
</div>
136+
`
137+
: nothing}
138+
109139
<div class="chat-compose">
110140
<label class="field chat-compose__field">
111141
<span>Message</span>
@@ -114,7 +144,9 @@ export function renderChat(props: ChatProps) {
114144
?disabled=${!props.connected}
115145
@keydown=${(e: KeyboardEvent) => {
116146
if (e.key !== "Enter") return;
147+
if (e.isComposing || e.keyCode === 229) return;
117148
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
149+
if (!props.connected) return;
118150
e.preventDefault();
119151
if (canCompose) props.onSend();
120152
}}
@@ -132,10 +164,10 @@ export function renderChat(props: ChatProps) {
132164
</button>
133165
<button
134166
class="btn primary"
135-
?disabled=${!props.connected || props.sending}
167+
?disabled=${!props.connected}
136168
@click=${props.onSend}
137169
>
138-
${props.sending ? "Sending…" : "Send"}
170+
${isBusy ? "Queue" : "Send"}
139171
</button>
140172
</div>
141173
</div>

0 commit comments

Comments
 (0)