feat(vscode): replace message queuing with promptAsync and queued UI indicator (#7029)

* feat(vscode): replace message queuing with promptAsync and queued UI indicator

* ci: re-trigger Kilo Code Review

* fix: message ID monotonicity and image restoration on failed sends

- Extract Identifier.ascending() utility with proper monotonic counter
  instead of hardcoded counter=1 that broke same-millisecond ordering
- Restore image attachments (not just text) when sendMessageFailed fires
- Add filename to FileAttachment type for round-trip preservation
- Add replace() to useImageAttachments hook

* fix: create optimistic file parts for image attachments in queued messages

Add FilePart type to Part union so optimistic messages include image
previews immediately. Previously, queued image messages only showed
text until the server echoed back the real parts.

* fix: clear optimistic parts when server confirms message

The server's message.updated SSE event does NOT include parts — they
arrive individually via part.updated events. Without clearing optimistic
parts on confirmation, both optimistic and real parts coexist, causing
duplicate images/text in the UI.

* fix: truncate message ID hex to 12 chars matching server's 6-byte format

The server's Identifier.ascending() writes 6 bytes big-endian (48 bits)
producing 12 hex chars. Our .toString(16) produced 14 hex chars (full
53-bit value), causing client IDs to sort BEFORE server assistant IDs
("18bdf..." < "f7b3..."). This triggered the server's loop exit
condition (lastUser.id < lastAssistant.id) after the first turn,
silently dropping all queued messages.

* fix: scope failed draft restore to the active session

Prevent sendMessageFailed from clobbering a new draft if the user
switched sessions between sending and the failure arriving.
This commit is contained in:
Marius
2026-03-16 12:22:08 +01:00
committed by GitHub
parent 20edbff4fb
commit 488f726674
9 changed files with 310 additions and 58 deletions
+38 -11
View File
@@ -336,6 +336,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
.parse(message.files)
await this.handleSendMessage(
message.text,
typeof message.messageID === "string" ? message.messageID : undefined,
message.sessionID,
message.providerID,
message.modelID,
@@ -362,8 +363,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
case "clearSession":
this.currentSession = null
this.trackedSessionIds.clear()
this.syncedChildSessions.clear()
break
case "loadMessages":
// Don't await: allow parallel loads so rapid session switching
@@ -546,6 +545,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
void this.handleImportAndSend(
message.cloudSessionId,
message.text,
typeof message.messageID === "string" ? message.messageID : undefined,
message.providerID,
message.modelID,
message.agent,
@@ -1272,6 +1272,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async handleImportAndSend(
cloudSessionId: string,
text: string,
messageID?: string,
providerID?: string,
modelID?: string,
agent?: string,
@@ -1340,10 +1341,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
try {
const editorContext = await this.gatherEditorContext()
await this.client.session.prompt(
if (messageID) {
this.connectionService.recordMessageSessionId(messageID, session.id)
}
await this.client.session.promptAsync(
{
sessionID: session.id,
directory: workspaceDir,
messageID,
parts,
model: providerID && modelID ? { providerID, modelID } : undefined,
agent,
@@ -1355,9 +1361,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
} catch (err) {
console.error("[Kilo New] Failed to send message after cloud import:", err)
this.postMessage({
type: "error",
message: err instanceof Error ? err.message : "Failed to send message after import",
type: "sendMessageFailed",
error: err instanceof Error ? err.message : "Failed to send message after import",
text,
sessionID: session.id,
messageID,
files,
})
}
}
@@ -1425,9 +1434,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
/**
* Handle sending a message from the webview.
* Uses promptAsync (fire-and-forget) — the server queues concurrent prompts
* internally, so the client doesn't need to block on busy sessions.
*/
private async handleSendMessage(
text: string,
messageID?: string,
sessionID?: string,
providerID?: string,
modelID?: string,
@@ -1437,12 +1449,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
): Promise<void> {
if (!this.client) {
this.postMessage({
type: "error",
message: "Not connected to CLI backend",
type: "sendMessageFailed",
error: "Not connected to CLI backend",
text,
sessionID,
messageID,
files,
})
return
}
let targetSessionID = sessionID
try {
const workspaceDir = this.getWorkspaceDirectory(sessionID || this.currentSession?.id)
@@ -1461,10 +1478,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}
const targetSessionID = sessionID || this.currentSession?.id
targetSessionID = sessionID || this.currentSession?.id
if (!targetSessionID) {
throw new Error("No session available")
}
this.trackedSessionIds.add(targetSessionID)
// Build parts array with file context and user text
const parts: Array<TextPartInput | FilePartInput> = []
@@ -1480,10 +1498,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const editorContext = await this.gatherEditorContext()
await this.client.session.prompt(
if (messageID) {
this.connectionService.recordMessageSessionId(messageID, targetSessionID)
}
await this.client.session.promptAsync(
{
sessionID: targetSessionID,
directory: workspaceDir,
messageID,
parts,
model: providerID && modelID ? { providerID, modelID } : undefined,
agent,
@@ -1495,8 +1518,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to send message:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to send message",
type: "sendMessageFailed",
error: getErrorMessage(error) || "Failed to send message",
text,
sessionID: targetSessionID,
messageID,
files,
})
}
}
@@ -21,6 +21,7 @@ import { CloudImportDialog } from "./CloudImportDialog"
import { FeedbackDialog } from "./FeedbackDialog"
import { VscodeSessionTurn } from "./VscodeSessionTurn"
import { WorkingIndicator } from "../shared/WorkingIndicator"
import { activeUserMessageID as getActiveUserMessageID } from "../../context/session-queue"
const KiloLogo = (): JSX.Element => {
const iconsBaseUri = (window as { ICONS_BASE_URI?: string }).ICONS_BASE_URI || ""
@@ -72,7 +73,13 @@ export const MessageList: Component<MessageListProps> = (props) => {
.slice(0, 3),
)
const lastUserMessageID = createMemo(() => userMessages().at(-1)?.id)
const activeUserID = createMemo(() => getActiveUserMessageID(session.messages(), session.statusInfo()))
const activeUserIndex = createMemo(() => {
const active = activeUserID()
if (!active) return -1
return userMessages().findIndex((msg) => msg.id === active)
})
return (
<div class="message-list-container">
@@ -130,13 +137,21 @@ export const MessageList: Component<MessageListProps> = (props) => {
</Show>
<Show when={!session.loading()}>
<For each={userMessages()}>
{(msg) => (
<VscodeSessionTurn
sessionID={session.currentSessionID() ?? ""}
messageID={msg.id}
lastUserMessageID={lastUserMessageID()}
/>
)}
{(msg, index) => {
const queued = createMemo(() => {
const active = activeUserIndex()
if (active === -1) return false
return index() > active
})
return (
<VscodeSessionTurn
sessionID={session.currentSessionID() ?? ""}
messageID={msg.id}
queued={queued()}
/>
)
}}
</For>
<WorkingIndicator />
</Show>
@@ -171,10 +171,9 @@ export const PromptInput: Component = () => {
const isBusy = () => session.status() === "busy"
const isDisabled = () => !server.isConnected()
const canSend = () =>
(text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0) &&
!isBusy() &&
!isDisabled()
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
const canSend = () => hasInput() && !isDisabled()
const showStop = () => isBusy() && !hasInput()
const placeholder = () => {
switch (server.connectionState()) {
case "connecting":
@@ -228,11 +227,38 @@ export const PromptInput: Component = () => {
}
if (message.type === "triggerTask") {
if (isBusy() || isDisabled()) return
if (isDisabled()) return
const sel = session.selected()
session.sendMessage(message.text, sel?.providerID, sel?.modelID)
}
if (message.type === "sendMessageFailed") {
const failed = message as import("../../types/messages").SendMessageFailedMessage
// Only restore draft if the failure is for the current session and the
// input is empty (user hasn't started typing something new).
const target = failed.sessionID ?? "__new__"
if (target === sessionKey() && !text().trim() && imageAttach.images().length === 0) {
if (failed.text) {
setText(failed.text)
setGhostText("")
if (textareaRef) {
textareaRef.value = failed.text
adjustHeight()
textareaRef.focus()
}
}
const images = (failed.files ?? [])
.filter((f) => f.mime.startsWith("image/") && f.url.startsWith("data:"))
.map((f) => ({
id: crypto.randomUUID(),
filename: f.filename ?? "image",
mime: f.mime,
dataUrl: f.url,
}))
if (images.length > 0) imageAttach.replace(images)
}
}
if (message.type === "action" && message.action === "focusInput") {
textareaRef?.focus()
}
@@ -424,10 +450,10 @@ export const PromptInput: Component = () => {
const pending = reviewComments()
const review = pending.length > 0 ? formatReviewCommentsMarkdown(pending) : ""
const message = draft && review ? `${review}\n\n${draft}` : draft || review
if ((!message && imgs.length === 0) || isBusy() || isDisabled()) return
if ((!message && imgs.length === 0) || isDisabled()) return
const mentionFiles = mention.parseFileAttachments(draft)
const imgFiles = imgs.map((img) => ({ mime: img.mime, url: img.dataUrl }))
const imgFiles = imgs.map((img) => ({ mime: img.mime, url: img.dataUrl, filename: img.filename }))
const allFiles = [...mentionFiles, ...imgFiles]
const sel = session.selected()
@@ -611,7 +637,7 @@ export const PromptInput: Component = () => {
</Button>
</Tooltip>
<Show
when={isBusy()}
when={showStop()}
fallback={
<Tooltip value={language.t("prompt.action.send")} placement="top">
<Button
@@ -45,7 +45,7 @@ function getFilename(path: string): string {
interface VscodeSessionTurnProps {
sessionID: string
messageID: string
lastUserMessageID?: string
queued?: boolean
}
export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
@@ -167,6 +167,7 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
message={msg() as unknown as Parameters<typeof UserMessageDisplay>[0]["message"]}
parts={parts() as unknown as Parameters<typeof UserMessageDisplay>[0]["parts"]}
interrupted={interrupted()}
queued={props.queued}
/>
</div>
@@ -0,0 +1,25 @@
import type { Message, SessionStatusInfo } from "../types/messages"
// Find the user message whose turn the server is actively processing.
// Any user message after this one is "queued" (waiting for its turn).
export function activeUserMessageID(messages: Message[], status: SessionStatusInfo) {
// Walk backward to find a non-completed assistant — its parent is the active turn
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i]
if (msg.role !== "assistant") continue
if (typeof msg.time?.completed === "number") continue
if (!msg.parentID) break
const parent = messages.find((item) => item.id === msg.parentID)
if (parent?.role === "user") return parent.id
break
}
// No pending assistant found — if busy, the last user message is the active turn
if (status.type === "idle") return undefined
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i].role === "user") return messages[i].id
}
return undefined
}
@@ -38,9 +38,11 @@ import type {
AgentInfo,
ExtensionMessage,
FileAttachment,
SendMessageFailedMessage,
} from "../types/messages"
import { removeSessionPermissions, upsertPermission } from "./permission-queue"
import { computeStatus, calcTotalCost, calcContextUsage } from "./session-utils"
import { Identifier } from "../utils/id"
// Store structure for messages and parts
interface SessionStore {
@@ -208,6 +210,10 @@ export const SessionProvider: ParentComponent = (props) => {
// Cloud session preview state
const [cloudPreviewId, setCloudPreviewId] = createSignal<string | null>(null)
// Tracks optimistic messageIDs that haven't been confirmed by the server yet.
// Prevents handleMessagesLoaded from wiping them when it replaces the array.
const pendingOptimistic = new Map<string, Set<string>>()
// Store for sessions, messages, parts, todos, modelSelections, agentSelections
const [store, setStore] = createStore<SessionStore>({
sessions: {},
@@ -450,6 +456,10 @@ export const SessionProvider: ParentComponent = (props) => {
if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false)
break
case "sendMessageFailed":
handleSendMessageFailed(message as unknown as SendMessageFailedMessage)
break
case "cloudSessionDataLoaded":
handleCloudSessionDataLoaded(message.cloudSessionId, message.title, message.messages)
break
@@ -503,7 +513,19 @@ export const SessionProvider: ParentComponent = (props) => {
function handleMessagesLoaded(sessionID: string, messages: Message[]) {
batch(() => {
if (sessionID === currentSessionID()) setLoading(false)
setStore("messages", sessionID, messages)
// Preserve optimistic messages that haven't been confirmed yet.
// The server may not have created the message record by the time
// this session's messages are loaded (e.g. on session switch).
const pending = pendingOptimistic.get(sessionID)
if (pending && pending.size > 0) {
const loadedIds = new Set(messages.map((m) => m.id))
const current = store.messages[sessionID] ?? []
const orphans = current.filter((m) => pending.has(m.id) && !loadedIds.has(m.id))
setStore("messages", sessionID, [...messages, ...orphans])
} else {
setStore("messages", sessionID, messages)
}
// Also extract parts from messages
for (const msg of messages) {
@@ -515,31 +537,33 @@ export const SessionProvider: ParentComponent = (props) => {
}
function handleMessageCreated(message: Message) {
// Message confirmed by server — no longer optimistic.
// Clear placeholder parts so they don't duplicate alongside real parts
// arriving via individual part.updated events (the server's message.updated
// SSE event does NOT include parts).
const pending = pendingOptimistic.get(message.sessionID)
const wasOptimistic = pending?.has(message.id)
pending?.delete(message.id)
if (wasOptimistic) {
setStore(
"parts",
produce((p) => {
delete p[message.id]
}),
)
}
setStore("messages", message.sessionID, (msgs = []) => {
// Check if message already exists (update case)
const existingIndex = msgs.findIndex((m) => m.id === message.id)
if (existingIndex >= 0) {
// Check if message already exists (optimistic or update case).
// Since we now use the same messageID for optimistic and server messages,
// this naturally handles the optimistic→real transition.
const idx = msgs.findIndex((m) => m.id === message.id)
if (idx >= 0) {
const updated = [...msgs]
updated[existingIndex] = { ...msgs[existingIndex], ...message }
updated[idx] = { ...msgs[idx], ...message }
return updated
}
// Replace optimistic user message if one exists
if (message.role === "user") {
const optimisticIdx = msgs.findIndex((m) => m.id.startsWith("optimistic-") && m.role === "user")
if (optimisticIdx >= 0) {
const updated = [...msgs]
// Clean up optimistic parts
const old = msgs[optimisticIdx]
setStore(
"parts",
produce((parts) => {
delete parts[old.id]
}),
)
updated[optimisticIdx] = message
return updated
}
}
return [...msgs, message]
})
@@ -615,6 +639,11 @@ export const SessionProvider: ParentComponent = (props) => {
delete map[sessionID]
}),
)
// Session is idle — any remaining pending optimistic IDs are either
// already confirmed (messageCreated removed them) or orphaned (queued
// callbacks were dropped on abort). Clean up the tracking set; the
// messages themselves will be reconciled on the next messagesLoaded.
pendingOptimistic.delete(sessionID)
}
}
@@ -669,6 +698,32 @@ export const SessionProvider: ParentComponent = (props) => {
setQuestionErrors((prev) => new Set(prev).add(requestID))
}
/**
* Handle a failed send: remove the optimistic message from the store
* and show a toast. The PromptInput restores the draft text separately
* by listening for the same sendMessageFailed event.
*/
function handleSendMessageFailed(message: SendMessageFailedMessage) {
if (message.sessionID && message.messageID) {
pendingOptimistic.get(message.sessionID)?.delete(message.messageID)
batch(() => {
setStore("messages", message.sessionID!, (msgs = []) => msgs.filter((m) => m.id !== message.messageID))
setStore(
"parts",
produce((parts) => {
delete parts[message.messageID!]
}),
)
})
}
showToast({
variant: "error",
title: language.t("prompt.toast.promptSendFailed.title") ?? "Failed to send message",
description: message.error,
})
}
/**
* BFS walk over message parts to discover all session IDs in a session's
* family tree (self + subagents + sub-subagents). Reads directly from the
@@ -736,6 +791,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
function handleSessionDeleted(sessionID: string) {
pendingOptimistic.delete(sessionID)
batch(() => {
// Collect message IDs so we can clean up their parts
const msgs = store.messages[sessionID] ?? []
@@ -907,6 +963,8 @@ export const SessionProvider: ParentComponent = (props) => {
return
}
const messageID = Identifier.ascending("message")
const preview = cloudPreviewId()
if (preview) {
const agent = selectedAgentName() !== defaultAgent() ? selectedAgentName() : undefined
@@ -914,6 +972,7 @@ export const SessionProvider: ParentComponent = (props) => {
type: "importAndSend",
cloudSessionId: preview,
text,
messageID,
providerID,
modelID,
agent,
@@ -925,17 +984,36 @@ export const SessionProvider: ParentComponent = (props) => {
const sid = currentSessionID()
if (sid) {
const tempId = `optimistic-${crypto.randomUUID()}`
const now = Date.now()
const temp: Message = {
id: tempId,
id: messageID,
sessionID: sid,
role: "user",
createdAt: new Date(now).toISOString(),
time: { created: now },
}
// Track this optimistic message so handleMessagesLoaded preserves it
const pending = pendingOptimistic.get(sid) ?? new Set()
pending.add(messageID)
pendingOptimistic.set(sid, pending)
const parts: Part[] = []
if (text) {
parts.push({ type: "text" as const, id: Identifier.ascending("part"), messageID, text })
}
for (const file of files ?? []) {
parts.push({
type: "file" as const,
id: Identifier.ascending("part"),
messageID,
mime: file.mime,
url: file.url,
filename: file.filename,
})
}
setStore("messages", sid, (msgs = []) => [...msgs, temp])
setStore("parts", tempId, [{ type: "text" as const, id: `${tempId}-text`, text }])
setStore("parts", messageID, parts)
// The optimistic message is now in the DOM but the session status is
// still "idle" (the CLI backend hasn't started yet), so the auto-scroll
// ResizeObserver won't scroll on its own. Force scroll to bottom so the
@@ -948,6 +1026,7 @@ export const SessionProvider: ParentComponent = (props) => {
vscode.postMessage({
type: "sendMessage",
text,
messageID,
sessionID: sid,
providerID,
modelID,
@@ -1056,10 +1135,6 @@ export const SessionProvider: ParentComponent = (props) => {
setCurrentSessionID(undefined)
setCloudPreviewId(null)
setLoading(false)
setPermissions([])
setRespondingPermissions(new Set<string>())
setQuestions([])
setQuestionErrors(new Set<string>())
setPendingAgentSelection(defaultAgent())
vscode.postMessage({ type: "clearSession" })
}
@@ -33,6 +33,8 @@ export function useImageAttachments() {
const clear = () => setImages([])
const replace = (next: ImageAttachment[]) => setImages(next)
const handlePaste = (event: ClipboardEvent) => {
const items = Array.from(event.clipboardData?.items ?? [])
const imageItems = items.filter((item) => item.kind === "file" && ACCEPTED_IMAGE_TYPES.includes(item.type))
@@ -71,6 +73,7 @@ export function useImageAttachments() {
add,
remove,
clear,
replace,
handlePaste,
handleDragOver,
handleDragLeave,
@@ -34,6 +34,13 @@ export interface TextPart extends BasePart {
text: string
}
export interface FilePart extends BasePart {
type: "file"
mime: string
url: string
filename?: string
}
export interface ToolPart extends BasePart {
type: "tool"
tool: string
@@ -62,7 +69,7 @@ export interface StepFinishPart extends BasePart {
}
}
export type Part = TextPart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart
export type Part = TextPart | FilePart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart
// Part delta for streaming updates
export interface PartDelta {
@@ -374,6 +381,15 @@ export interface ErrorMessage {
sessionID?: string
}
export interface SendMessageFailedMessage {
type: "sendMessageFailed"
error: string
text: string
sessionID?: string
messageID?: string
files?: FileAttachment[]
}
export interface PartUpdatedMessage {
type: "partUpdated"
sessionID?: string
@@ -1018,6 +1034,7 @@ export type ExtensionMessage =
| ReadyMessage
| ConnectionStateMessage
| ErrorMessage
| SendMessageFailedMessage
| PartUpdatedMessage
| SessionStatusMessage
| PermissionRequestMessage
@@ -1100,11 +1117,13 @@ export type ExtensionMessage =
export interface FileAttachment {
mime: string
url: string
filename?: string
}
export interface SendMessageRequest {
type: "sendMessage"
text: string
messageID?: string
sessionID?: string
providerID?: string
modelID?: string
@@ -1164,6 +1183,7 @@ export interface ImportAndSendMessage {
type: "importAndSend"
cloudSessionId: string
text: string
messageID?: string
providerID?: string
modelID?: string
agent?: string
@@ -0,0 +1,60 @@
type Prefix = "message" | "part"
const prefixes: Record<Prefix, string> = {
message: "msg",
part: "prt",
}
const RANDOM_LENGTH = 14
let lastTimestamp = 0
let counter = 0
// Browser-side sortable IDs for optimistic queued messages/parts.
// They need to sort the same way as backend-generated IDs so queued turns
// stay in order before SSE catches up.
export const Identifier = {
ascending(prefix: Prefix) {
const currentTimestamp = Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter += 1
// Server uses 6 bytes big-endian (48 bits). Truncate to 12 hex chars
// to match — without this, the full BigInt produces 14 chars which
// breaks the server's lexicographic exit condition (lastUser < lastAssistant).
const sortable = (BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter))
.toString(16)
.slice(-12)
.padStart(12, "0")
return `${prefixes[prefix]}_${sortable}${randomBase62(RANDOM_LENGTH)}`
},
}
function randomBase62(length: number) {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const bytes = getRandomBytes(length)
let result = ""
for (let i = 0; i < length; i += 1) {
result += chars[bytes[i] % 62]
}
return result
}
function getRandomBytes(length: number) {
const bytes = new Uint8Array(length)
if (typeof globalThis.crypto?.getRandomValues === "function") {
globalThis.crypto.getRandomValues(bytes)
return bytes
}
for (let i = 0; i < length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256)
}
return bytes
}