diff --git a/.changeset/edit-queued-messages.md b/.changeset/edit-queued-messages.md new file mode 100644 index 00000000000..349424385c7 --- /dev/null +++ b/.changeset/edit-queued-messages.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@kilocode/cli": patch +--- + +Edit queued messages in VS Code before they are sent, while preserving their text and attachments. diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 6aa909b4086..27f8083d75e 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -752,6 +752,8 @@ export function UserMessageDisplay(props: { text?: string copyText?: string header?: JSX.Element + edit?: { label: string; onClick: () => void; disabled?: boolean } + queuedDisabled?: boolean onDelete?: () => void onFork?: () => void onRevert?: () => void @@ -835,6 +837,7 @@ export function UserMessageDisplay(props: { icon="close-small" size="normal" variant="ghost" + disabled={props.queuedDisabled} onMouseDown={(e) => e.preventDefault()} onClick={(event) => { event.stopPropagation() @@ -846,6 +849,28 @@ export function UserMessageDisplay(props: { ) + const Edit = () => ( + + {(edit) => ( + + event.preventDefault()} + onClick={(event) => { + event.stopPropagation() + edit().onClick() + }} + aria-label={edit().label} + /> + + )} + + ) + return (
@@ -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": {