Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/edit-queued-messages.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,8 @@ export function UserMessageDisplay(props: {
text?: string
copyText?: string
header?: JSX.Element
edit?: { label: string; onClick: () => void; disabled?: boolean }
queuedDisabled?: boolean

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this is one of those boolean fields I have mentioned in other venues. Nothing wrong with it, but just to underline what I was referring to.

onDelete?: () => void
onFork?: () => void
onRevert?: () => void
Expand Down Expand Up @@ -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()
Expand All @@ -846,6 +849,28 @@ export function UserMessageDisplay(props: {
</Show>
)

const Edit = () => (
<Show when={props.edit}>
{(edit) => (
<Tooltip value={edit().label} placement="right" gutter={4}>
<IconButton
data-slot="user-message-edit"
icon="edit"
size="small"
variant="ghost"
disabled={props.queuedDisabled || edit().disabled}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
edit().onClick()
}}
aria-label={edit().label}
/>
</Tooltip>
)}
</Show>
)

return (
<GrowBox animate={!!props.animate} fade class="w-full min-w-0 self-stretch max-w-full">
<div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}>
Expand Down Expand Up @@ -885,6 +910,7 @@ export function UserMessageDisplay(props: {
<Show when={!text() && !props.header && props.queued}>
<div data-slot="user-message-queued-indicator">
<TextShimmer text={i18n.t("ui.message.queued")} />
<Edit />
<Delete />
</div>
</Show>
Expand All @@ -900,6 +926,7 @@ export function UserMessageDisplay(props: {
<GrowBox animate={!!props.animate} open={!!props.queued}>
<div data-slot="user-message-queued-indicator">
<TextShimmer text={i18n.t("ui.message.queued")} />
<Edit />
<Delete />
</div>
</GrowBox>
Expand Down
20 changes: 15 additions & 5 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -2425,24 +2425,34 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}

private async handleDeleteMessage(sessionID: string, messageID: string): Promise<void> {
if (!this.client) {
private async handleDeleteMessage(sessionID: string, messageID: string, requestID?: string): Promise<void> {
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({
type: "error",
message: getErrorMessage(error) || "Failed to delete message",
sessionID,
})
this.postMessage({ ...result, success: false })
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ function createClient(options?: {
createDeferred?: Deferred<{ data: ReturnType<typeof mkCreatedSession> }>
abortFailures?: string[]
abortDeferred?: Deferred<void>
deleteResult?: unknown
deleteError?: boolean
supportDeferred?: Deferred<{ data: { available: boolean; reason?: string } }>
sandboxDeferred?: Deferred<{ data: unknown }>
sandboxStarted?: Deferred<void>
Expand All @@ -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<Record<string, unknown>> = []
const reverted: Array<Record<string, unknown>> = []
const created: Array<Record<string, unknown>> = []
Expand All @@ -90,6 +98,7 @@ function createClient(options?: {
stopped,
aborted,
deleted,
deletedMessages,
prompted,
reverted,
created,
Expand Down Expand Up @@ -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<string, unknown>) => {
Expand Down Expand Up @@ -171,7 +185,7 @@ function createClient(options?: {
}
}

function createConnection(client: ReturnType<typeof createClient>) {
function createConnection(client: ReturnType<typeof createClient> | null) {
const state = { value: undefined as boolean | undefined, revision: 0, pending: Promise.resolve() }
return {
sandboxPreference: {
Expand Down Expand Up @@ -251,13 +265,14 @@ type ProviderInternals = {
refreshGitStatus: (directory?: string, sessionID?: string) => Promise<void>
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
handleDeleteSession: (sid: string) => Promise<void>
handleDeleteMessage: (sid: string, mid: string, rid?: string) => Promise<void>
}

function makeProvider(client: ReturnType<typeof createClient>) {
function makeProvider(client: ReturnType<typeof createClient> | 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) => {
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 10 additions & 1 deletion packages/kilo-vscode/tests/unit/prompt-drafts.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,6 +19,7 @@ beforeEach(() => {
drafts.clear()
reviewDrafts.clear()
imageDrafts.clear()
mentionDrafts.clear()
})

describe("deleteDraftsForSession", () => {
Expand All @@ -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")

Expand All @@ -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", () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ export const ChatView: Component<ChatViewProps> = (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<string>()
Expand Down Expand Up @@ -356,6 +361,9 @@ export const ChatView: Component<ChatViewProps> = (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}
Expand Down Expand Up @@ -388,6 +396,9 @@ export const ChatView: Component<ChatViewProps> = (props) => {
<Show when={!props.readonly}>
<PromptInput
blocked={blocked}
edit={editing()}
onEditComplete={() => setEditing(undefined)}
onEditReady={setEditable}
suggesting={suggesting}
questioning={questioning}
worktree={props.worktree}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: two boolean fields added ... I still believe all of them could be aggregated as single, reactive, state field, which most items lack for what I've read so far.

/** 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. */
Expand Down Expand Up @@ -1327,6 +1330,9 @@ export const MessageList: Component<MessageListProps> = (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}
Expand All @@ -1341,6 +1347,9 @@ export const MessageList: Component<MessageListProps> = (props) => {
<TranscriptRowView
row={lookup().get(key)!}
onForkMessage={props.onForkMessage}
onEditMessage={props.onEditMessage}
queuedDisabled={props.queuedDisabled}
editDisabled={props.editDisabled}
highlight={highlight}
activeSearch={activeKey() === key}
activeSearchPartID={activeKey() === key ? activeMatch()?.partId : undefined}
Expand All @@ -1358,6 +1367,9 @@ export const MessageList: Component<MessageListProps> = (props) => {
{(row) => (
<TranscriptRowView
row={row}
onEditMessage={props.onEditMessage}
queuedDisabled={props.queuedDisabled}
editDisabled={props.editDisabled}
activeSearch={activeKey() === row.key}
activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined}
activeSearchPartFile={activeKey() === row.key ? activeMatch()?.partFile : undefined}
Expand Down
Loading
Loading