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:
@@ -5,6 +5,7 @@ import MessagePart from "./message-part"
|
|||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
message: Message
|
message: Message
|
||||||
messageInfo?: any
|
messageInfo?: any
|
||||||
|
isQueued?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageItem(props: MessageItemProps) {
|
export default function MessageItem(props: MessageItemProps) {
|
||||||
@@ -49,6 +50,10 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
|
<Show when={props.isQueued && isUser()}>
|
||||||
|
<div class="message-queued-badge">QUEUED</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={errorMessage()}>
|
<Show when={errorMessage()}>
|
||||||
<div class="message-error-block">⚠️ {errorMessage()}</div>
|
<div class="message-error-block">⚠️ {errorMessage()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -47,18 +47,29 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
const displayItems = createMemo(() => {
|
const displayItems = createMemo(() => {
|
||||||
const items: DisplayItem[] = []
|
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) {
|
for (const message of props.messages) {
|
||||||
const messageInfo = props.messagesInfo?.get(message.id)
|
const messageInfo = props.messagesInfo?.get(message.id)
|
||||||
const textParts = message.parts.filter((p) => p.type === "text" && !p.synthetic)
|
const textParts = message.parts.filter((p) => p.type === "text" && !p.synthetic)
|
||||||
const toolParts = message.parts.filter((p) => p.type === "tool")
|
const toolParts = message.parts.filter((p) => p.type === "tool")
|
||||||
const reasoningParts = message.parts.filter((p) => p.type === "reasoning")
|
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) {
|
if (textParts.length > 0 || reasoningParts.length > 0 || messageInfo?.error) {
|
||||||
items.push({
|
items.push({
|
||||||
type: "message",
|
type: "message",
|
||||||
data: {
|
data: {
|
||||||
...message,
|
...message,
|
||||||
parts: [...textParts, ...reasoningParts],
|
parts: [...textParts, ...reasoningParts],
|
||||||
|
isQueued,
|
||||||
},
|
},
|
||||||
messageInfo,
|
messageInfo,
|
||||||
})
|
})
|
||||||
@@ -156,7 +167,7 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MessageItem message={item.data} messageInfo={item.messageInfo} />
|
<MessageItem message={item.data} messageInfo={item.messageInfo} isQueued={item.data.isQueued} />
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ interface PromptInputProps {
|
|||||||
|
|
||||||
export default function PromptInput(props: PromptInputProps) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
const [prompt, setPrompt] = createSignal("")
|
const [prompt, setPrompt] = createSignal("")
|
||||||
const [sending, setSending] = createSignal(false)
|
|
||||||
const [history, setHistory] = createSignal<string[]>([])
|
const [history, setHistory] = createSignal<string[]>([])
|
||||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||||
const [isFocused, setIsFocused] = createSignal(false)
|
const [isFocused, setIsFocused] = createSignal(false)
|
||||||
@@ -396,9 +395,18 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const text = prompt().trim()
|
const text = prompt().trim()
|
||||||
const currentAttachments = attachments()
|
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 {
|
try {
|
||||||
await addToHistory(props.instanceFolder, text)
|
await addToHistory(props.instanceFolder, text)
|
||||||
|
|
||||||
@@ -407,20 +415,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
setHistoryIndex(-1)
|
setHistoryIndex(-1)
|
||||||
|
|
||||||
await props.onSend(text, currentAttachments)
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to send message:", error)
|
console.error("Failed to send message:", error)
|
||||||
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
|
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
|
||||||
textareaRef?.focus()
|
textareaRef?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -612,7 +610,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
textareaRef?.focus()
|
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()
|
const instance = () => getActiveInstance()
|
||||||
|
|
||||||
@@ -720,16 +718,14 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
disabled={sending() || props.disabled}
|
disabled={props.disabled}
|
||||||
rows={1}
|
rows={1}
|
||||||
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="send-button" onClick={handleSend} disabled={!canSend()} aria-label="Send message">
|
<button class="send-button" onClick={handleSend} disabled={!canSend()} aria-label="Send message">
|
||||||
<Show when={sending()} fallback={<span class="send-icon">▶</span>}>
|
<span class="send-icon">▶</span>
|
||||||
<span class="spinner-small" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="prompt-input-hints">
|
<div class="prompt-input-hints">
|
||||||
|
|||||||
@@ -189,9 +189,22 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-content {
|
.message-content {
|
||||||
display: flex;
|
padding-top: 6px;
|
||||||
flex-direction: column;
|
line-height: 1.6;
|
||||||
gap: 8px;
|
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 {
|
.message-text {
|
||||||
|
|||||||
@@ -487,34 +487,40 @@ function handleMessageUpdate(instanceId: string, event: any): void {
|
|||||||
const part = event.properties?.part
|
const part = event.properties?.part
|
||||||
if (!part) return
|
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) => {
|
setSessions((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const instanceSessions = new Map(prev.get(instanceId))
|
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)
|
next.set(instanceId, instanceSessions)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
@@ -522,36 +528,51 @@ function handleMessageUpdate(instanceId: string, event: any): void {
|
|||||||
const info = event.properties?.info
|
const info = event.properties?.info
|
||||||
if (!info) return
|
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) => {
|
setSessions((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const instanceSessions = new Map(prev.get(instanceId))
|
const instanceSessions = new Map(prev.get(instanceId))
|
||||||
const updatedSession = instanceSessions.get(info.sessionID)
|
const session = instanceSessions.get(info.sessionID)
|
||||||
if (updatedSession) {
|
|
||||||
const messagesInfo = new Map(updatedSession.messagesInfo)
|
if (!session) return prev
|
||||||
messagesInfo.set(info.id, info)
|
|
||||||
instanceSessions.set(info.sessionID, { ...updatedSession, messagesInfo })
|
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)
|
next.set(instanceId, instanceSessions)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
@@ -639,6 +660,36 @@ async function sendMessage(
|
|||||||
throw new Error("Session not found")
|
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[] = [
|
const parts: any[] = [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
|
|||||||
Reference in New Issue
Block a user