@@ -885,6 +910,7 @@ export function UserMessageDisplay(props: {
+
@@ -900,6 +926,7 @@ export function UserMessageDisplay(props: {
+
diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts
index 02df3c584d0..55cbba341aa 100644
--- a/packages/kilo-vscode/src/KiloProvider.ts
+++ b/packages/kilo-vscode/src/KiloProvider.ts
@@ -1163,7 +1163,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.checkpoint(message.sessionID, () => this.handleUnrevertSession(message.sessionID))
break
case "deleteMessage":
- await this.handleDeleteMessage(message.sessionID, message.messageID)
+ await this.handleDeleteMessage(message.sessionID, message.messageID, message.requestID)
break
case "permissionResponse":
await handlePermissionResponse(
@@ -2425,17 +2425,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
- private async handleDeleteMessage(sessionID: string, messageID: string): Promise
{
- if (!this.client) {
+ private async handleDeleteMessage(sessionID: string, messageID: string, requestID?: string): Promise {
+ const result = {
+ type: "deleteMessageResult" as const,
+ sessionID,
+ messageID,
+ ...(requestID !== undefined ? { requestID } : {}),
+ }
+ const client = this.client
+ if (!client) {
this.postMessage({ type: "error", message: "Not connected to CLI backend", sessionID })
+ this.postMessage({ ...result, success: false })
return
}
try {
- await this.client.session.deleteMessage(
- { sessionID, messageID, directory: this.getWorkspaceDirectory(sessionID) },
+ const response = await client.session.deleteMessage(
+ { sessionID, messageID, directory: this.getWorkspaceDirectory(sessionID), queued: true },
{ throwOnError: true },
)
+ this.postMessage({ ...result, success: response.data === true })
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to delete message:", error)
this.postMessage({
@@ -2443,6 +2452,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
message: getErrorMessage(error) || "Failed to delete message",
sessionID,
})
+ this.postMessage({ ...result, success: false })
}
}
diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts
index e18a92e3374..f08ab19c084 100644
--- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts
+++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts
@@ -70,6 +70,8 @@ function createClient(options?: {
createDeferred?: Deferred<{ data: ReturnType }>
abortFailures?: string[]
abortDeferred?: Deferred
+ deleteResult?: unknown
+ deleteError?: boolean
supportDeferred?: Deferred<{ data: { available: boolean; reason?: string } }>
sandboxDeferred?: Deferred<{ data: unknown }>
sandboxStarted?: Deferred
@@ -79,6 +81,12 @@ function createClient(options?: {
const stopped: { sessionID: string; directory?: string }[] = []
const aborted: { sessionID: string; directory?: string }[] = []
const deleted: { sessionID: string; directory?: string }[] = []
+ const deletedMessages: Array<{
+ sessionID: string
+ messageID: string
+ directory?: string
+ queued?: boolean
+ }> = []
const prompted: Array> = []
const reverted: Array> = []
const created: Array> = []
@@ -90,6 +98,7 @@ function createClient(options?: {
stopped,
aborted,
deleted,
+ deletedMessages,
prompted,
reverted,
created,
@@ -133,6 +142,11 @@ function createClient(options?: {
if (options?.deleteDeferred) return options.deleteDeferred.promise
return { data: {} }
},
+ deleteMessage: async (params: { sessionID: string; messageID: string; directory?: string; queued?: boolean }) => {
+ deletedMessages.push(params)
+ if (options?.deleteError) throw new Error("delete failed")
+ return { data: options?.deleteResult }
+ },
},
sandbox: {
support: async (params: Record) => {
@@ -171,7 +185,7 @@ function createClient(options?: {
}
}
-function createConnection(client: ReturnType) {
+function createConnection(client: ReturnType | null) {
const state = { value: undefined as boolean | undefined, revision: 0, pending: Promise.resolve() }
return {
sandboxPreference: {
@@ -251,13 +265,14 @@ type ProviderInternals = {
refreshGitStatus: (directory?: string, sessionID?: string) => Promise
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise
handleDeleteSession: (sid: string) => Promise
+ handleDeleteMessage: (sid: string, mid: string, rid?: string) => Promise
}
-function makeProvider(client: ReturnType) {
+function makeProvider(client: ReturnType | null) {
const connection = createConnection(client)
const provider = new KiloProvider({} as never, connection as never)
const internal = provider as unknown as ProviderInternals
- internal.connectionState = "connected"
+ internal.connectionState = client ? "connected" : "disconnected"
const sent: unknown[] = []
internal.webview = {
postMessage: async (message: unknown) => {
@@ -1159,6 +1174,31 @@ describe("KiloProvider.handleDeleteSession / background processes", () => {
})
})
+describe("KiloProvider.handleDeleteMessage", () => {
+ const ids = { sessionID: "s1", messageID: "m1", requestID: "r1" }
+
+ it.each([true, false, undefined, "true"])("confirms only a true queued deletion result: %p", async (result) => {
+ const client = createClient({ deleteResult: result })
+ const { internal, sent } = makeProvider(client)
+ await internal.handleDeleteMessage(ids.sessionID, ids.messageID, ids.requestID)
+ expect(client.deletedMessages).toEqual([{ sessionID: "s1", messageID: "m1", directory: "/repo", queued: true }])
+ expect(sent).toContainEqual({ type: "deleteMessageResult", ...ids, success: result === true })
+ })
+
+ it.each([true, false])("confirms removal failures when connected=%p", async (connected) => {
+ const error = spyOn(console, "error").mockImplementation(() => {})
+ const { internal, sent } = makeProvider(connected ? createClient({ deleteError: true }) : null)
+ await internal.handleDeleteMessage(ids.sessionID, ids.messageID, ids.requestID)
+ expect(sent).toContainEqual({
+ type: "error",
+ message: connected ? "delete failed" : "Not connected to CLI backend",
+ sessionID: ids.sessionID,
+ })
+ expect(sent).toContainEqual({ type: "deleteMessageResult", ...ids, success: false })
+ error.mockRestore()
+ })
+})
+
describe("KiloProvider.handleLoadMessages / slim payload", () => {
it("shows a cost alert even when cost arrives after the session is idle", () => {
const client = createClient()
diff --git a/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts b/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts
index d23bcb97625..76052e1211c 100644
--- a/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts
+++ b/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts
@@ -1,6 +1,12 @@
import { beforeEach, describe, it, expect } from "bun:test"
import { createEffect, createRoot, createSignal, on } from "solid-js"
-import { deleteDraftsForSession, drafts, imageDrafts, reviewDrafts } from "../../webview-ui/src/utils/draft-store"
+import {
+ deleteDraftsForSession,
+ drafts,
+ imageDrafts,
+ mentionDrafts,
+ reviewDrafts,
+} from "../../webview-ui/src/utils/draft-store"
import {
createdDraftKey,
movePromptDraft,
@@ -13,6 +19,7 @@ beforeEach(() => {
drafts.clear()
reviewDrafts.clear()
imageDrafts.clear()
+ mentionDrafts.clear()
})
describe("deleteDraftsForSession", () => {
@@ -22,6 +29,7 @@ describe("deleteDraftsForSession", () => {
drafts.set("prompt:default:session:b", "draft b")
reviewDrafts.set("prompt:default:session:a", [])
imageDrafts.set("prompt:default:session:a", [])
+ mentionDrafts.set("prompt:default:session:a", { paths: ["file with spaces.ts"], sessions: [] })
deleteDraftsForSession("a")
@@ -30,6 +38,7 @@ describe("deleteDraftsForSession", () => {
expect(drafts.get("prompt:default:session:b")).toBe("draft b")
expect(reviewDrafts.has("prompt:default:session:a")).toBe(false)
expect(imageDrafts.has("prompt:default:session:a")).toBe(false)
+ expect(mentionDrafts.has("prompt:default:session:a")).toBe(false)
})
it("is a no-op when given an empty id", () => {
diff --git a/packages/kilo-vscode/tests/unit/session-queue.test.ts b/packages/kilo-vscode/tests/unit/session-queue.test.ts
index 8a4fd463a7a..cf90765b04d 100644
--- a/packages/kilo-vscode/tests/unit/session-queue.test.ts
+++ b/packages/kilo-vscode/tests/unit/session-queue.test.ts
@@ -4,12 +4,19 @@ import {
messageTurns,
partitionTurns,
queuedUserMessageIDs,
+ removeQueuedMessage,
stableMessageTurns,
visibleMessages,
visibleParts,
type RevertBoundary,
} from "../../webview-ui/src/context/session-queue"
-import type { Message, Part, SessionStatusInfo } from "../../webview-ui/src/types/messages"
+import type {
+ ExtensionMessage,
+ Message,
+ Part,
+ SessionStatusInfo,
+ WebviewMessage,
+} from "../../webview-ui/src/types/messages"
const base = {
sessionID: "session",
@@ -634,3 +641,57 @@ describe("activeUserMessageID", () => {
expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_3")
})
})
+
+describe("queued deletion cleanup", () => {
+ const setup = (timeout = 10_000) => {
+ const listeners = new Set<(message: ExtensionMessage) => void>()
+ const sent: WebviewMessage[] = []
+ const promise = removeQueuedMessage(
+ {
+ onMessage: (handler) => {
+ listeners.add(handler)
+ return () => {
+ listeners.delete(handler)
+ }
+ },
+ postMessage: (message) => {
+ sent.push(message)
+ },
+ },
+ "session",
+ "message",
+ timeout,
+ )
+ return {
+ promise,
+ listeners,
+ sent,
+ emit: (message: ExtensionMessage) => listeners.forEach((handler) => handler(message)),
+ }
+ }
+
+ it.each([true, false])("settles only the matching acknowledgment: %p", async (success) => {
+ const state = setup()
+ const request = state.sent.at(0)
+ if (request?.type !== "deleteMessage") throw new Error("Missing deletion request")
+ state.emit({ type: "connectionState", state: "connecting" })
+ state.emit({ ...request, type: "deleteMessageResult", requestID: "unrelated", success: true })
+ expect(state.listeners.size).toBe(1)
+ state.emit({ ...request, type: "deleteMessageResult", success })
+ expect(await state.promise).toBe(success)
+ expect(state.listeners.size).toBe(0)
+ })
+
+ const interruptions: Array = [
+ undefined,
+ { type: "connectionState", state: "disconnected" },
+ { type: "connectionState", state: "error" },
+ { type: "sessionDeleted", sessionID: "session" },
+ ]
+ it.each(interruptions)("releases the waiter and listener after timeout or interruption: %p", async (message) => {
+ const state = setup(1)
+ if (message) state.emit(message)
+ expect(await state.promise).toBe(false)
+ expect(state.listeners.size).toBe(0)
+ })
+})
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
index 567276ddaf1..e5dce529e24 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
@@ -68,7 +68,12 @@ export const ChatView: Component = (props) => {
// the very first send instead of growing once the message lands.
const hasMessages = () => session.messages().length > 0 || session.submitting()
- // "Continue in Worktree" state
+ const [editable, setEditable] = createSignal(false)
+ const [editing, setEditing] = createSignal<{ sessionID: string; messageID: string }>()
+ const edit = (sessionID: string, messageID: string) => {
+ if (props.readonly || !editable() || editing()) return
+ setEditing({ sessionID, messageID })
+ }
const [transferring, setTransferring] = createSignal(false)
const [transferDetail, setTransferDetail] = createSignal("")
const [repoBranch, setRepoBranch] = createSignal()
@@ -356,6 +361,9 @@ export const ChatView: Component = (props) => {
onSelectSession={props.onSelectSession}
onShowHistory={props.onShowHistory}
onForkMessage={props.onForkMessage}
+ onEditMessage={edit}
+ queuedDisabled={editing()?.sessionID === id() && !!editing()}
+ editDisabled={!editable() || !!editing()}
questions={standaloneQuestions}
suggestions={standaloneSuggestions}
readonly={props.readonly}
@@ -388,6 +396,9 @@ export const ChatView: Component = (props) => {
setEditing(undefined)}
+ onEditReady={setEditable}
suggesting={suggesting}
questioning={questioning}
worktree={props.worktree}
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx
index 234a7d6c768..c47d368cfc3 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx
@@ -85,12 +85,15 @@ interface MessageListProps {
onSelectSession?: (id: string) => void
onShowHistory?: () => void
onForkMessage?: (sessionId: string, messageId: string) => void
+ onEditMessage?: (sessionID: string, messageID: string) => void
/** Non-tool question requests to render inline at the bottom of the message list */
questions?: () => QuestionRequest[]
/** Non-tool suggestion requests to render inline at the bottom of the message list */
suggestions?: () => SuggestionRequest[]
/** When true (subagent viewer), replace the welcome screen with an initializing indicator */
readonly?: boolean
+ queuedDisabled?: boolean
+ editDisabled?: boolean
/** Optionally replace the standard welcome content while the conversation is empty. */
emptyState?: () => JSX.Element
/** Announce transcript changes as a live log. Disable for multi-session surfaces with concurrent streams. */
@@ -1327,6 +1330,9 @@ export const MessageList: Component = (props) => {
row={row}
index={index()}
onForkMessage={props.onForkMessage}
+ onEditMessage={props.onEditMessage}
+ queuedDisabled={props.queuedDisabled}
+ editDisabled={props.editDisabled}
highlight={highlight}
activeSearch={activeKey() === row.key}
activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined}
@@ -1341,6 +1347,9 @@ export const MessageList: Component = (props) => {
= (props) => {
{(row) => (
string | undefined) | undefined): stri
interface PromptInputProps {
blocked?: () => boolean
+ edit?: { sessionID: string; messageID: string }
+ onEditReady?: (ready: boolean) => void
+ onEditComplete?: () => void
/** When true, session is busy only because a suggestion is pending — treat as idle for input */
suggesting?: () => boolean
/** When true, session is busy only because a question is pending — treat as idle for input */
@@ -237,6 +242,7 @@ export const PromptInput: Component = (props) => {
pendingDraftKey(props.pendingSessionID ?? session.draftSessionID()) ??
"new"
const draftKey = () => scopeDraftKey(boxKey(), rawKey())
+ const locked = () => !!props.edit && props.edit.sessionID === session.currentSessionID()
const saveDraft = (
key: string,
next: string,
@@ -399,6 +405,11 @@ export const PromptInput: Component = (props) => {
const scroll = scrollDrafts.get(key) ?? 0
setText(draft)
mention.seedFromText(draft)
+ const refs = mentionDrafts.get(key)
+ if (refs) {
+ mention.seedFromParts(refs.paths, draft)
+ mention.seedSessions(refs.sessions, draft)
+ }
setReviewComments(pending)
imageAttach.replace(imageDrafts.get(key) ?? [])
setEnhancing(false)
@@ -540,7 +551,7 @@ export const PromptInput: Component = (props) => {
config(),
globalConfig(),
)
- const isDisabled = () => !server.isConnected()
+ const isDisabled = () => !server.isConnected() || locked()
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
@@ -576,6 +587,65 @@ export const PromptInput: Component = (props) => {
}
}
+ const canEdit = () =>
+ server.isConnected() && !hasInput() && !enhancing() && !speech.active() && !terminal.pending() && !git.pending()
+ createEffect(() => props.onEditReady?.(canEdit()))
+
+ const edit = async (request: NonNullable) => {
+ try {
+ if (!canEdit() || request.sessionID !== session.currentSessionID()) return
+ const parts = session.getParts(request.messageID)
+ if (
+ parts.some(
+ (part) =>
+ part.type !== "text" &&
+ (part.type !== "file" ||
+ (!part.source && !(part.mime.startsWith("image/") && part.url.startsWith("data:")))),
+ )
+ )
+ return
+ const state = revertPromptState(parts)
+ if (!state.text.trim() && state.images.length === 0) return
+ const key = draftKey()
+ mention.closeMention()
+ slash.close()
+ ghost.dismiss()
+ if (!(await session.deleteQueuedMessage(request.sessionID, request.messageID))) return
+ if (!session.sessions().some((item) => item.id === request.sessionID)) return
+ const active = draftKey() === key && textareaRef?.isConnected
+ const value = [state.text, active ? text() : drafts.get(key)].filter(Boolean).join("\n\n")
+ const images = [
+ ...state.images.map((image) => ({ ...image, id: crypto.randomUUID(), filename: image.filename ?? "image" })),
+ ...(active ? imageAttach.images() : (imageDrafts.get(key) ?? [])),
+ ]
+ const comments = active ? reviewComments() : (reviewDrafts.get(key) ?? [])
+ savePromptDraft(key, value, comments, images)
+ mentionDrafts.set(key, { paths: state.paths, sessions: state.sessions })
+ if (!active) return
+ enhanceCounter++
+ preEnhanceText = null
+ history.reset()
+ setText(value)
+ mention.seedFromParts(state.paths, value)
+ mention.seedSessions(state.sessions, value)
+ replaceReviewComments(comments)
+ imageAttach.replace(images)
+ adjustHeight()
+ textareaRef?.focus()
+ textareaRef?.setSelectionRange(value.length, value.length)
+ } finally {
+ props.onEditComplete?.()
+ }
+ }
+ createEffect(
+ on(
+ () => props.edit,
+ (request) => {
+ if (request) void edit(request)
+ },
+ ),
+ )
+
const unsubAutoApprove = vscode.onMessage((message) => {
if (message.type === "autoApproveState") {
setAutoApprove(message.active)
@@ -774,6 +844,7 @@ export const PromptInput: Component = (props) => {
vscode.postMessage({ type: "requestAutoApproveState" })
onCleanup(() => {
+ props.onEditReady?.(false)
// Persist current draft before unmounting
saveDraft(draftKey(), text(), reviewComments(), imageAttach.images())
if (sandboxRetry) clearTimeout(sandboxRetry)
@@ -824,6 +895,10 @@ export const PromptInput: Component = (props) => {
}
const handlePaste = (e: ClipboardEvent) => {
+ if (locked()) {
+ e.preventDefault()
+ return
+ }
imageAttach.handlePaste(e)
// After pasting text, the textarea content changes but the layout may not
// have reflowed yet, causing the caret position to be visually out of sync.
@@ -850,6 +925,7 @@ export const PromptInput: Component = (props) => {
}
const handleKeyDown = (e: KeyboardEvent) => {
+ if (locked()) return
// Undo enhanced prompt with Ctrl+Z / ⌘Z
if (e.key === "z" && (e.metaKey || e.ctrlKey) && !e.shiftKey && preEnhanceText !== null) {
e.preventDefault()
@@ -1121,6 +1197,7 @@ export const PromptInput: Component = (props) => {
drafts.delete(draftKey())
reviewDrafts.delete(draftKey())
imageDrafts.delete(draftKey())
+ mentionDrafts.delete(draftKey())
scrollDrafts.delete(draftKey())
if (textareaRef) textareaRef.style.height = "auto"
return
@@ -1146,6 +1223,7 @@ export const PromptInput: Component = (props) => {
drafts.delete(draftKey())
reviewDrafts.delete(draftKey())
imageDrafts.delete(draftKey())
+ mentionDrafts.delete(draftKey())
scrollDrafts.delete(draftKey())
if (textareaRef) textareaRef.style.height = "auto"
matched.action()
@@ -1231,6 +1309,7 @@ export const PromptInput: Component = (props) => {
drafts.delete(key)
reviewDrafts.delete(key)
imageDrafts.delete(key)
+ mentionDrafts.delete(key)
scrollDrafts.delete(key)
history.append(draft)
if (draftKey() !== key) return
@@ -1251,7 +1330,13 @@ export const PromptInput: Component = (props) => {
classList={{ "prompt-input-container--dragging": imageAttach.dragging() }}
onDragOver={imageAttach.handleDragOver}
onDragLeave={imageAttach.handleDragLeave}
- onDrop={imageAttach.handleDrop}
+ onDrop={(event) => {
+ if (locked()) {
+ event.preventDefault()
+ return
+ }
+ imageAttach.handleDrop(event)
+ }}
>
0}>
= (props) => {
}}
onScroll={syncHighlightScroll}
aria-disabled={isDisabled()}
+ readOnly={locked()}
rows={1}
dir="auto"
/>
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx
index f356615178d..3accf86d04f 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx
@@ -18,6 +18,7 @@ interface TranscriptRowViewProps {
row: TranscriptRow
index?: number
onForkMessage?: (sessionId: string, messageId: string) => void
+ onEditMessage?: (sessionID: string, messageID: string) => void
/** Part behind the currently hovered/focused task-timeline bar, if any. */
highlight?: () => TimelineHighlight | undefined
activeSearch?: boolean
@@ -27,6 +28,8 @@ interface TranscriptRowViewProps {
/** For a multi-file apply_patch match, the specific file within that part. */
activeSearchPartFile?: string
readonly?: boolean
+ queuedDisabled?: boolean
+ editDisabled?: boolean
}
export const TranscriptRowView: Component = (props) => {
@@ -64,11 +67,20 @@ export const TranscriptRowView: Component = (props) => {
parts={row().parts}
interrupted={row().interrupted}
queued={row().queued}
+ onEdit={
+ row().queued && !props.readonly && props.onEditMessage
+ ? () => props.onEditMessage?.(row().message.sessionID, row().message.id)
+ : undefined
+ }
+ queuedDisabled={props.queuedDisabled || !server.isConnected()}
+ editDisabled={props.editDisabled}
onFork={
props.onForkMessage ? () => props.onForkMessage?.(row().message.sessionID, row().message.id) : undefined
}
onDelete={
- row().queued ? () => session.deleteQueuedMessage(row().message.sessionID, row().message.id) : undefined
+ row().queued && !props.readonly
+ ? () => session.deleteQueuedMessage(row().message.sessionID, row().message.id)
+ : undefined
}
onRevert={
row().answered
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeUserMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeUserMessage.tsx
index c9aae9142c9..51eced3679e 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeUserMessage.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeUserMessage.tsx
@@ -3,18 +3,23 @@ import { UserMessageDisplay } from "@kilocode/kilo-ui/message-part"
import { partReview } from "../../../../src/shared/review-comments"
import type { Message, Part, TextPart } from "../../types/messages"
import { ReviewComments } from "./ReviewComments"
+import { useLanguage } from "../../context/language"
interface VscodeUserMessageProps {
message: Message
parts: Part[]
interrupted?: boolean
queued?: boolean
+ onEdit?: () => void
+ queuedDisabled?: boolean
+ editDisabled?: boolean
onDelete?: () => void
onFork?: () => void
onRevert?: () => void
}
export const VscodeUserMessage: Component = (props) => {
+ const language = useLanguage()
const text = createMemo(() => props.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic))
const review = createMemo(() => {
const part = text()
@@ -36,6 +41,12 @@ export const VscodeUserMessage: Component = (props) => {
}
interrupted={props.interrupted}
queued={props.queued}
+ edit={
+ props.onEdit
+ ? { label: language.t("common.edit"), onClick: props.onEdit, disabled: props.editDisabled }
+ : undefined
+ }
+ queuedDisabled={props.queuedDisabled}
onDelete={props.onDelete}
onFork={props.onFork}
onRevert={props.onRevert}
diff --git a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts
index d1c0990a542..d80ea436b6d 100644
--- a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts
+++ b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts
@@ -1,4 +1,5 @@
import type { Message, Part, SessionInfo, SessionStatusInfo } from "../types/messages"
+import type { useVSCode } from "./vscode"
export type RevertBoundary = Pick, "messageID" | "partID">
@@ -233,3 +234,32 @@ export function partitionTurns(turns: MessageTurn[], ids: ReadonlySet, q
if (idx === -1) return { virtual: visible, direct: [] as MessageTurn[], queued: waiting }
return { virtual: visible.slice(0, idx), direct: visible.slice(idx), queued: waiting }
}
+
+export function removeQueuedMessage(
+ vscode: Pick, "onMessage" | "postMessage">,
+ sessionID: string,
+ messageID: string,
+ timeout = 10_000,
+) {
+ const requestID = crypto.randomUUID()
+ return new Promise((resolve) => {
+ const finish = (success: boolean) => {
+ clearTimeout(timer)
+ unsubscribe()
+ resolve(success)
+ }
+ const unsubscribe = vscode.onMessage((message) => {
+ if (message.type === "connectionState" && (message.state === "disconnected" || message.state === "error")) {
+ finish(false)
+ return
+ }
+ if (message.type === "sessionDeleted" && message.sessionID === sessionID) {
+ finish(false)
+ return
+ }
+ if (message.type === "deleteMessageResult" && message.requestID === requestID) finish(message.success)
+ })
+ const timer = setTimeout(() => finish(false), timeout)
+ vscode.postMessage({ type: "deleteMessage", sessionID, messageID, requestID })
+ })
+}
diff --git a/packages/kilo-vscode/webview-ui/src/context/session-types.ts b/packages/kilo-vscode/webview-ui/src/context/session-types.ts
index 9bc29fb394b..f439eb3715d 100644
--- a/packages/kilo-vscode/webview-ui/src/context/session-types.ts
+++ b/packages/kilo-vscode/webview-ui/src/context/session-types.ts
@@ -159,7 +159,7 @@ export interface SessionContextValue {
// Actions
revertSession: (messageID: string, partID?: string) => void
unrevertSession: () => void
- deleteQueuedMessage: (sessionID: string, messageID: string) => void
+ deleteQueuedMessage: (sessionID: string, messageID: string) => Promise
sendMessage: (
text: string,
providerID?: string,
diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx
index ffb64fe6e5e..888ad62b713 100644
--- a/packages/kilo-vscode/webview-ui/src/context/session.tsx
+++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx
@@ -88,7 +88,7 @@ import { sessionVariantKeys, transferVariants, variantKey } from "./session-vari
import { createSessionVariants } from "./session-variants"
import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model"
import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments"
-import { activeUserMessageID, visibleMessages as filterVisibleMessages } from "./session-queue"
+import { activeUserMessageID, removeQueuedMessage, visibleMessages as filterVisibleMessages } from "./session-queue"
import { clearSessionDraftDiscarded, deleteDraftsForSession } from "../utils/draft-store"
import { createAbortState } from "./abort-state"
import { continuation } from "./session-continuation"
@@ -1941,6 +1941,8 @@ export const SessionProvider: ParentComponent = (props) => {
// Splices the message from the store and deletes its parts.
function handleMessageRemoved(sessionID: string, messageID: string) {
+ pendingOptimistic.get(sessionID)?.delete(messageID)
+ finishSubmission(messageID)
optimisticParts.delete(messageID)
setStore("messages", sessionID, (msgs = []) => msgs.filter((m) => m.id !== messageID))
dropMessageTools(sessionID, messageID)
@@ -2741,13 +2743,11 @@ export const SessionProvider: ParentComponent = (props) => {
vscode.postMessage({ type: "unrevertSession", sessionID: id })
}
- // Clear local send bookkeeping and request deletion. The message stays visible
- // until messageRemoved confirms deletion; a false response leaves it in place.
- function deleteQueuedMessage(sessionID: string, messageID: string) {
- if (!server.isConnected()) return
- pendingOptimistic.get(sessionID)?.delete(messageID)
- finishSubmission(messageID)
- vscode.postMessage({ type: "deleteMessage", sessionID, messageID })
+ async function deleteQueuedMessage(sessionID: string, messageID: string) {
+ if (!server.isConnected()) return false
+ const removed = await removeQueuedMessage(vscode, sessionID, messageID)
+ if (removed) handleMessageRemoved(sessionID, messageID)
+ return removed
}
function syncSession(sessionID: string, parentSessionID = currentSessionID(), scope: "task" | "inspector" = "task") {
diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts
index 61fe1d8d3bc..0002cb0d1cd 100644
--- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts
+++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts
@@ -240,6 +240,14 @@ export interface MessageRemovedMessage {
messageID: string
}
+export interface DeleteMessageResultMessage {
+ type: "deleteMessageResult"
+ sessionID: string
+ messageID: string
+ requestID?: string
+ success: boolean
+}
+
export interface MessagesLoadedMessage {
type: "messagesLoaded"
sessionID: string
@@ -1451,6 +1459,7 @@ export type ExtensionMessage =
| SessionUpdatedMessage
| SessionDeletedMessage
| MessageRemovedMessage
+ | DeleteMessageResultMessage
| MessagesLoadedMessage
| SessionModelUsageLoadedMessage
| SessionModelUsageChangedMessage
diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts
index 818031b35c2..229bab01e92 100644
--- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts
+++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts
@@ -87,6 +87,7 @@ export interface DeleteMessageRequest {
type: "deleteMessage"
sessionID: string
messageID: string
+ requestID?: string
}
export interface PermissionResponseRequest {
diff --git a/packages/kilo-vscode/webview-ui/src/utils/draft-store.ts b/packages/kilo-vscode/webview-ui/src/utils/draft-store.ts
index c2c24dde146..78bc63ca3ef 100644
--- a/packages/kilo-vscode/webview-ui/src/utils/draft-store.ts
+++ b/packages/kilo-vscode/webview-ui/src/utils/draft-store.ts
@@ -1,7 +1,9 @@
import type { ReviewCommentEntry } from "../types/messages"
import type { ImageAttachment } from "../hooks/useImageAttachments"
+import type { RevertPromptState } from "../context/session-utils"
import { pendingDraftKey, sessionDraftKey } from "./prompt-drafts"
+export const mentionDrafts = new Map>()
export const drafts = new Map()
export const reviewDrafts = new Map()
export const imageDrafts = new Map()
@@ -17,6 +19,7 @@ export function savePromptDraft(
images: ImageAttachment[],
scroll = 0,
) {
+ if (!text) mentionDrafts.delete(key)
if (text) drafts.set(key, text)
else drafts.delete(key)
if (comments.length > 0) reviewDrafts.set(key, comments)
@@ -30,7 +33,7 @@ export function savePromptDraft(
function remove(raw: string | undefined) {
if (!raw) return
const suffix = `:${raw}`
- for (const map of [drafts, reviewDrafts, imageDrafts, scrollDrafts]) {
+ for (const map of [drafts, reviewDrafts, imageDrafts, scrollDrafts, mentionDrafts]) {
for (const key of map.keys()) {
if (typeof key === "string" && key.endsWith(suffix)) map.delete(key)
}
diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
index a7f5464a689..bd45bbd59af 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
@@ -46,6 +46,12 @@ export const MessagesQuery = Schema.Struct({
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
before: Schema.optional(Schema.String),
})
+// kilocode_change start
+export const DeleteMessageQuery = Schema.Struct({
+ ...WorkspaceRoutingQueryFields,
+ queued: Schema.optional(QueryBoolean),
+})
+// kilocode_change end
export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
export const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String),
@@ -423,7 +429,7 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
- query: WorkspaceRoutingQuery,
+ query: DeleteMessageQuery, // kilocode_change
success: described(Schema.Boolean, "Successfully deleted message"),
error: [HttpApiError.BadRequest, ApiNotFoundError, SessionBusyError],
}).annotateMerge(
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
index ec1a3e0ac0c..8bebcdb4460 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
@@ -29,6 +29,7 @@ import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/htt
import { InstanceHttpApi } from "../api"
import {
CommandPayload,
+ DeleteMessageQuery, // kilocode_change
DiffQuery,
ForkPayload,
InitPayload,
@@ -403,15 +404,18 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
+ query: typeof DeleteMessageQuery.Type // kilocode_change
}) {
yield* requireSession(ctx.params.sessionID)
// kilocode_change start - allow deleting prompts that are queued behind the active turn
- const remove = yield* runState.assertNotBusy(ctx.params.sessionID).pipe(
- Effect.as(true),
- Effect.catchTag("SessionBusyError", () =>
- KiloSessionPromptQueue.drop(ctx.params.sessionID, ctx.params.messageID),
- ),
- )
+ const remove = yield* ctx.query.queued === true
+ ? KiloSessionPromptQueue.drop(ctx.params.sessionID, ctx.params.messageID)
+ : runState.assertNotBusy(ctx.params.sessionID).pipe(
+ Effect.as(true),
+ Effect.catchTag("SessionBusyError", () =>
+ KiloSessionPromptQueue.drop(ctx.params.sessionID, ctx.params.messageID),
+ ),
+ )
// A false result means the message is not in the waiting list. It may have
// already started, or the ID may be stale. Leave the message untouched.
if (!remove) return false
diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts
index edb2812654a..9172eebe537 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/public.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts
@@ -74,6 +74,7 @@ const QueryParameterSchemas: Record = {
"GET /session start": { type: "number" },
"GET /session roots": QueryBooleanOpenApi,
"GET /session limit": { type: "number" },
+ "DELETE /session/{sessionID}/message/{messageID} queued": QueryBooleanOpenApi, // kilocode_change
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
"GET /vcs/diff context": { type: "integer", minimum: 0 },
"GET /api/session limit": { type: "number" },
diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts
index 884389c8b5c..ce0ce75ebc5 100644
--- a/packages/opencode/test/server/httpapi-session.test.ts
+++ b/packages/opencode/test/server/httpapi-session.test.ts
@@ -1088,7 +1088,7 @@ describe("session HttpApi", () => {
// kilocode_change start - deleting a prompt that already started is a successful no-op
it.live(
- "returns false when an active prompt wins the deletion race",
+ "only deletes queued messages before they start",
() => {
const release = Promise.withResolvers()
return Effect.gen(function* () {
@@ -1100,25 +1100,32 @@ describe("session HttpApi", () => {
const messageID = MessageID.ascending()
const headers = { "x-kilo-directory": dir, "content-type": "application/json" }
- const prompt = yield* request(pathFor(SessionPaths.promptAsync, { sessionID: session.id }), {
- method: "POST",
- headers,
- body: JSON.stringify({
- messageID,
- agent: "build",
- model: { providerID: "test", modelID: "test-model" },
- parts: [{ type: "text", text: "keep running" }],
- }),
- })
- expect(prompt.status).toBe(204)
+ const prompt = (messageID: MessageID) =>
+ request(pathFor(SessionPaths.promptAsync, { sessionID: session.id }), {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ messageID,
+ agent: "build",
+ model: { providerID: "test", modelID: "test-model" },
+ parts: [{ type: "text", text: "keep running" }],
+ }),
+ })
+ const remove = (messageID: MessageID) =>
+ requestJson(
+ `${pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID })}?queued=true`,
+ { method: "DELETE", headers },
+ )
+ expect((yield* prompt(messageID)).status).toBe(204)
yield* llm.wait(1)
- expect(
- yield* requestJson(pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID }), {
- method: "DELETE",
- headers,
- }),
- ).toBe(false)
+ expect(yield* remove(messageID)).toBe(false)
+ const queued = MessageID.ascending()
+ expect((yield* prompt(queued)).status).toBe(204)
+ yield* pollWithTimeout(
+ remove(queued).pipe(Effect.map((success) => (success ? true : undefined))),
+ "Queued prompt was not removed",
+ )
release.resolve()
yield* pollWithTimeout(
@@ -1128,9 +1135,11 @@ describe("session HttpApi", () => {
"Timed out waiting for active prompt to finish",
)
+ expect(yield* remove(messageID)).toBe(false)
const messages = yield* Session.use
.messages({ sessionID: session.id })
.pipe(provideInstanceEffect(dir), Effect.orDie)
+ expect(messages.some((message) => message.info.id === queued)).toBe(false)
expect(messages.some((message) => message.info.id === messageID)).toBe(true)
expect(
messages.some((message) => message.info.role === "assistant" && message.info.parentID === messageID),
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 3c55cba95cf..bc89cdbdfcf 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -4684,6 +4684,7 @@ export class Session2 extends HeyApiClient {
messageID: string
directory?: string
workspace?: string
+ queued?: boolean | "true" | "false"
},
options?: Options,
) {
@@ -4696,6 +4697,7 @@ export class Session2 extends HeyApiClient {
{ in: "path", key: "messageID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
+ { in: "query", key: "queued" },
],
},
],
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index c6f27033345..5d0b0f9885f 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -13449,6 +13449,7 @@ export type SessionDeleteMessageData = {
query?: {
directory?: string
workspace?: string
+ queued?: boolean | "true" | "false"
}
url: "/session/{sessionID}/message/{messageID}"
}
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 0f24e06896d..042b6a802eb 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -7319,6 +7319,22 @@
"type": "string"
},
"required": false
+ },
+ {
+ "name": "queued",
+ "in": "query",
+ "schema": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "string",
+ "enum": ["true", "false"]
+ }
+ ]
+ },
+ "required": false
}
],
"responses": {