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.
This commit is contained in:
Shantur Rathore
2025-10-24 16:38:42 +01:00
parent e3bc947195
commit 7be4248e20
5 changed files with 149 additions and 73 deletions

View File

@@ -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) {
</div>
<div class="message-content">
<Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">QUEUED</div>
</Show>
<Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div>
</Show>

View File

@@ -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) {
</div>
}
>
<MessageItem message={item.data} messageInfo={item.messageInfo} />
<MessageItem message={item.data} messageInfo={item.messageInfo} isQueued={item.data.isQueued} />
</Show>
)
}}

View File

@@ -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<string[]>([])
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<number>())
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<number>())
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" } : {}}
/>
</div>
<button class="send-button" onClick={handleSend} disabled={!canSend()} aria-label="Send message">
<Show when={sending()} fallback={<span class="send-icon"></span>}>
<span class="spinner-small" />
</Show>
<span class="send-icon"></span>
</button>
</div>
<div class="prompt-input-hints">

View File

@@ -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 {

View File

@@ -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,