From 7be4248e20f051fa7d555fc1a9262422c140cd4f Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 24 Oct 2025 16:38:42 +0100 Subject: [PATCH] Enable command queueing and fix message duplication issues - Remove sending() state blocking to allow queueing multiple messages - Add optimistic message creation with temp IDs for immediate UI feedback - Implement QUEUED badge display matching TUI behavior (accent color, bold) - Compute queued state: user message ID > last assistant message ID - Clear prompt input immediately before async send for better UX - Fix duplicate messages by properly finding and replacing temp messages - Fix duplicate parts by clearing optimistic parts when real message arrives - Fix state mutation bugs in message.part.updated handler (immutable updates) - Fix state mutation bugs in message.updated handler (immutable updates) Matches TUI implementation for consistent user experience across clients. --- src/components/message-item.tsx | 5 + src/components/message-stream.tsx | 13 ++- src/components/prompt-input.tsx | 32 +++---- src/index.css | 19 +++- src/stores/sessions.ts | 153 ++++++++++++++++++++---------- 5 files changed, 149 insertions(+), 73 deletions(-) diff --git a/src/components/message-item.tsx b/src/components/message-item.tsx index e2c1e6f8..44929d08 100644 --- a/src/components/message-item.tsx +++ b/src/components/message-item.tsx @@ -5,6 +5,7 @@ import MessagePart from "./message-part" interface MessageItemProps { message: Message messageInfo?: any + isQueued?: boolean } export default function MessageItem(props: MessageItemProps) { @@ -49,6 +50,10 @@ export default function MessageItem(props: MessageItemProps) {
+ +
QUEUED
+
+
⚠️ {errorMessage()}
diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx index 208bcb68..0f60a1a1 100644 --- a/src/components/message-stream.tsx +++ b/src/components/message-stream.tsx @@ -47,18 +47,29 @@ export default function MessageStream(props: MessageStreamProps) { const displayItems = createMemo(() => { const items: DisplayItem[] = [] + let lastAssistantMessageId = "" + for (let i = props.messages.length - 1; i >= 0; i--) { + if (props.messages[i].type === "assistant") { + lastAssistantMessageId = props.messages[i].id + break + } + } + for (const message of props.messages) { const messageInfo = props.messagesInfo?.get(message.id) const textParts = message.parts.filter((p) => p.type === "text" && !p.synthetic) const toolParts = message.parts.filter((p) => p.type === "tool") const reasoningParts = message.parts.filter((p) => p.type === "reasoning") + const isQueued = message.type === "user" && message.id > lastAssistantMessageId + if (textParts.length > 0 || reasoningParts.length > 0 || messageInfo?.error) { items.push({ type: "message", data: { ...message, parts: [...textParts, ...reasoningParts], + isQueued, }, messageInfo, }) @@ -156,7 +167,7 @@ export default function MessageStream(props: MessageStreamProps) {
} > - + ) }} diff --git a/src/components/prompt-input.tsx b/src/components/prompt-input.tsx index dc38f463..446b8fda 100644 --- a/src/components/prompt-input.tsx +++ b/src/components/prompt-input.tsx @@ -26,7 +26,6 @@ interface PromptInputProps { export default function PromptInput(props: PromptInputProps) { const [prompt, setPrompt] = createSignal("") - const [sending, setSending] = createSignal(false) const [history, setHistory] = createSignal([]) const [historyIndex, setHistoryIndex] = createSignal(-1) const [isFocused, setIsFocused] = createSignal(false) @@ -396,9 +395,18 @@ export default function PromptInput(props: PromptInputProps) { async function handleSend() { const text = prompt().trim() const currentAttachments = attachments() - if (!text || sending() || props.disabled) return + if (!text || props.disabled) return + + setPrompt("") + clearAttachments(props.instanceId, props.sessionId) + setIgnoredAtPositions(new Set()) + setPasteCount(0) + setImageCount(0) + + if (textareaRef) { + textareaRef.style.height = "auto" + } - setSending(true) try { await addToHistory(props.instanceFolder, text) @@ -407,20 +415,10 @@ export default function PromptInput(props: PromptInputProps) { setHistoryIndex(-1) await props.onSend(text, currentAttachments) - setPrompt("") - clearAttachments(props.instanceId, props.sessionId) - setIgnoredAtPositions(new Set()) - setPasteCount(0) - setImageCount(0) - - if (textareaRef) { - textareaRef.style.height = "auto" - } } catch (error) { console.error("Failed to send message:", error) alert("Failed to send message: " + (error instanceof Error ? error.message : String(error))) } finally { - setSending(false) textareaRef?.focus() } } @@ -612,7 +610,7 @@ export default function PromptInput(props: PromptInputProps) { textareaRef?.focus() } - const canSend = () => (prompt().trim().length > 0 || attachments().length > 0) && !sending() && !props.disabled + const canSend = () => (prompt().trim().length > 0 || attachments().length > 0) && !props.disabled const instance = () => getActiveInstance() @@ -720,16 +718,14 @@ export default function PromptInput(props: PromptInputProps) { onPaste={handlePaste} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} - disabled={sending() || props.disabled} + disabled={props.disabled} rows={1} style={attachments().length > 0 ? { "padding-top": "8px" } : {}} />
diff --git a/src/index.css b/src/index.css index a97f973a..3c49382e 100644 --- a/src/index.css +++ b/src/index.css @@ -189,9 +189,22 @@ body { } .message-content { - display: flex; - flex-direction: column; - gap: 8px; + padding-top: 6px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +.message-queued-badge { + display: inline-block; + background-color: var(--accent-color); + color: white; + font-weight: bold; + padding: 4px 12px; + border-radius: 4px; + margin-bottom: 12px; + font-size: 12px; + letter-spacing: 0.5px; } .message-text { diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index 9c3572e1..89d2c6f6 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -487,34 +487,40 @@ function handleMessageUpdate(instanceId: string, event: any): void { const part = event.properties?.part if (!part) return - const session = instanceSessions.get(part.sessionID) - if (!session) return - - let message = session.messages.find((m) => m.id === part.messageID) - - if (!message) { - message = { - id: part.messageID, - sessionId: part.sessionID, - type: "assistant", - parts: [part], - timestamp: Date.now(), - status: "streaming", - } - session.messages.push(message) - } else { - const partIndex = message.parts.findIndex((p: any) => p.id === part.id) - if (partIndex === -1) { - message.parts.push(part) - } else { - message.parts[partIndex] = part - } - } - setSessions((prev) => { const next = new Map(prev) const instanceSessions = new Map(prev.get(instanceId)) - instanceSessions.set(part.sessionID, { ...session }) + const session = instanceSessions.get(part.sessionID) + + if (!session) return prev + + const messages = [...session.messages] + const messageIndex = messages.findIndex((m) => m.id === part.messageID) + + if (messageIndex === -1) { + messages.push({ + id: part.messageID, + sessionId: part.sessionID, + type: "assistant", + parts: [part], + timestamp: Date.now(), + status: "streaming", + }) + } else { + const message = messages[messageIndex] + const parts = [...message.parts] + const partIndex = parts.findIndex((p: any) => p.id === part.id) + + if (partIndex === -1) { + parts.push(part) + } else { + parts[partIndex] = part + } + + messages[messageIndex] = { ...message, parts } + } + + instanceSessions.set(part.sessionID, { ...session, messages }) next.set(instanceId, instanceSessions) return next }) @@ -522,36 +528,51 @@ function handleMessageUpdate(instanceId: string, event: any): void { const info = event.properties?.info if (!info) return - const session = instanceSessions.get(info.sessionID) - if (!session) return - - let message = session.messages.find((m) => m.id === info.id) - - if (!message) { - message = { - id: info.id, - sessionId: info.sessionID, - type: info.role === "user" ? "user" : "assistant", - parts: [], - timestamp: info.time?.created || Date.now(), - status: "complete", - } - session.messages.push(message) - } else { - // Update existing message - replace temp message with real one - message.id = info.id - message.status = "complete" - } - setSessions((prev) => { const next = new Map(prev) const instanceSessions = new Map(prev.get(instanceId)) - const updatedSession = instanceSessions.get(info.sessionID) - if (updatedSession) { - const messagesInfo = new Map(updatedSession.messagesInfo) - messagesInfo.set(info.id, info) - instanceSessions.set(info.sessionID, { ...updatedSession, messagesInfo }) + const session = instanceSessions.get(info.sessionID) + + if (!session) return prev + + const messages = [...session.messages] + const messageIndex = messages.findIndex((m) => m.id === info.id) + const tempMessageIndex = messages.findIndex( + (m) => + m.id.startsWith("temp-") && + m.type === (info.role === "user" ? "user" : "assistant") && + m.status === "sending", + ) + + if (messageIndex > -1) { + messages[messageIndex] = { + ...messages[messageIndex], + status: "complete", + } + } else if (tempMessageIndex > -1) { + messages[tempMessageIndex] = { + id: info.id, + sessionId: info.sessionID, + type: info.role === "user" ? "user" : "assistant", + parts: [], + timestamp: info.time?.created || Date.now(), + status: "complete", + } + } else { + messages.push({ + id: info.id, + sessionId: info.sessionID, + type: info.role === "user" ? "user" : "assistant", + parts: [], + timestamp: info.time?.created || Date.now(), + status: "complete", + }) } + + const messagesInfo = new Map(session.messagesInfo) + messagesInfo.set(info.id, info) + + instanceSessions.set(info.sessionID, { ...session, messages, messagesInfo }) next.set(instanceId, instanceSessions) return next }) @@ -639,6 +660,36 @@ async function sendMessage( throw new Error("Session not found") } + const tempMessageId = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}` + + const textParts: any[] = [] + textParts.push({ + type: "text" as const, + text: prompt, + id: `${tempMessageId}-text`, + }) + + const optimisticMessage: Message = { + id: tempMessageId, + sessionId, + type: "user", + parts: textParts, + timestamp: Date.now(), + status: "sending", + } + + setSessions((prev) => { + const next = new Map(prev) + const instanceSessions = new Map(prev.get(instanceId)) + const session = instanceSessions.get(sessionId) + if (session) { + const messages = [...session.messages, optimisticMessage] + instanceSessions.set(sessionId, { ...session, messages }) + next.set(instanceId, instanceSessions) + } + return next + }) + const parts: any[] = [ { type: "text" as const,